1use std::collections::HashMap;
27use std::io::IsTerminal;
28use std::sync::Arc;
29use std::sync::mpsc::channel;
30
31use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
32use tokio::sync::broadcast;
33
34use rpi_agent::{AgentEvent, AgentMessage};
35use rpi_ai::types::{AssistantMessage, Content};
36use rpi_harness::agent_harness::{AgentHarness, AgentLane, HarnessRunOutcome};
37use rpi_tui::{
38 AutocompleteManager, CombinedAutocompleteProvider, Container, Editor, EditorOptions,
39 EditorStyle, FilePathAutocompleteProvider, Focusable, FollowMode, Loader, ProcessTerminal,
40 ScrollView, ScrollViewOptions, SlashCommand, SlashCommandAutocompleteProvider, Spacer,
41 StackChild, StackEntry, Text, TuiAltScreen, TUI, VStack, AssistantBlock,
42 AssistantMessageComponent, AssistantMessageOptions, AutocompleteSuggestions,
43 FooterComponent, SelectList, SelectItem, ThemeManager, ThemePreset,
44 ToolExecutionComponent, render_diff,
45 BashExecutionComponent, BashTruncation, UserMessageComponent,
46};
47
48#[allow(unused_imports)]
49use rpi_tui::BashStatus;
50
51use crate::args::Args;
52
53enum SlashCommandResult {
59 Exit,
61 ClearChat,
63 Unknown,
65 SendMessage(String),
67 Help,
69 Version,
71 Hotkeys,
73 SelectModel,
75 SelectThinking,
77 SelectTools,
79 SelectImages,
81 Armin,
83 Earendil,
85 SelectSession,
87 SelectTheme,
89 Compact,
91 Copy,
93 Unsupported(String),
95}
96
97fn handle_slash_command(text: &str) -> SlashCommandResult {
103 let parts: Vec<&str> = text.split_whitespace().collect();
104 if parts.is_empty() {
105 return SlashCommandResult::SendMessage(text.to_string());
106 }
107
108 let command = parts[0];
109 match command {
110 "/help" | "/?" => SlashCommandResult::Help,
111 "/clear" | "/new" => SlashCommandResult::ClearChat,
112 "/exit" | "/quit" | "/q" => SlashCommandResult::Exit,
113 "/version" | "/v" => SlashCommandResult::Version,
114 "/model" | "/m" => SlashCommandResult::SelectModel,
115 "/thinking" | "/think" => SlashCommandResult::SelectThinking,
116 "/tools" => SlashCommandResult::SelectTools,
117 "/images" => SlashCommandResult::SelectImages,
118 "/armin" => SlashCommandResult::Armin,
119 "/earendil" => SlashCommandResult::Earendil,
120 "/hotkeys" => SlashCommandResult::Hotkeys,
121 "/session" | "/resume" => SlashCommandResult::SelectSession,
122 "/theme" => SlashCommandResult::SelectTheme,
123 "/compact" => SlashCommandResult::Compact,
124 "/copy" => SlashCommandResult::Copy,
125 "/name" => SlashCommandResult::Unsupported("/name".to_string()),
127 "/settings"
129 | "/scoped-models"
130 | "/export"
131 | "/import"
132 | "/share"
133 | "/fork"
134 | "/clone"
135 | "/tree"
136 | "/trust"
137 | "/login"
138 | "/logout"
139 | "/reload" => SlashCommandResult::Unsupported(command.to_string()),
140 _ => SlashCommandResult::Unknown,
141 }
142}
143
144fn v1_slash_commands() -> Vec<SlashCommand> {
149 vec![
150 SlashCommand { name: "/help".into(), description: "Show available commands".into() },
151 SlashCommand { name: "/clear".into(), description: "Clear the conversation".into() },
152 SlashCommand { name: "/new".into(), description: "Clear the conversation".into() },
153 SlashCommand { name: "/exit".into(), description: "Exit the application".into() },
154 SlashCommand { name: "/quit".into(), description: "Exit the application".into() },
155 SlashCommand { name: "/version".into(), description: "Show version information".into() },
156 SlashCommand { name: "/model".into(), description: "Choose a model (selector)".into() },
157 SlashCommand { name: "/thinking".into(), description: "Set thinking level (selector)".into() },
158 SlashCommand { name: "/tools".into(), description: "Toggle tools on/off".into() },
159 SlashCommand { name: "/images".into(), description: "Toggle inline images".into() },
160 SlashCommand { name: "/session".into(), description: "List saved sessions".into() },
161 SlashCommand { name: "/theme".into(), description: "Choose a theme (selector)".into() },
162 SlashCommand { name: "/compact".into(), description: "Compact the conversation".into() },
163 SlashCommand { name: "/copy".into(), description: "Copy last reply to clipboard".into() },
164 SlashCommand { name: "/hotkeys".into(), description: "Show keyboard shortcuts".into() },
165 SlashCommand { name: "/armin".into(), description: "??? (easter egg)".into() },
166 SlashCommand { name: "/earendil".into(), description: "Announcement".into() },
167 ]
168}
169
170enum TuiMessage {
177 UserInput(String),
178 Exit,
179 ClearChat,
181 Compact,
183 Copy,
185}
186
187fn assistant_text(msg: &AssistantMessage) -> String {
190 msg.content
191 .iter()
192 .filter_map(|c| match c {
193 Content::Text(t) => Some(t.text.clone()),
194 _ => None,
195 })
196 .collect()
197}
198
199fn assistant_blocks(msg: &AssistantMessage) -> Vec<AssistantBlock> {
206 msg.content
207 .iter()
208 .filter_map(|c| match c {
209 Content::Text(t) => Some(AssistantBlock::Text(t.text.clone())),
210 Content::Thinking(t) => Some(AssistantBlock::Thinking(t.thinking.clone())),
211 _ => None,
212 })
213 .collect()
214}
215
216fn short_model_name(id: &str) -> String {
219 id.rsplit([':', '/'])
220 .next()
221 .filter(|s| !s.is_empty())
222 .unwrap_or(id)
223 .to_string()
224}
225
226#[derive(Clone, Copy, PartialEq, Eq)]
232enum RunStatus {
233 Idle,
234 Working,
235 Aborting,
236}
237
238#[derive(Clone, Copy, PartialEq, Eq)]
240enum SelectorKind {
241 Model,
243 Thinking,
245 Tools,
247 Images,
249 Session,
251 Theme,
253}
254
255struct TuiState {
258 current_assistant: std::sync::Mutex<Option<Arc<AssistantMessageComponent>>>,
260 tool_components: std::sync::Mutex<HashMap<String, Arc<ToolExecutionComponent>>>,
262 bash_components: std::sync::Mutex<HashMap<String, Arc<BashExecutionComponent>>>,
266 last_tool_comp: std::sync::Mutex<Option<Arc<ToolExecutionComponent>>>,
270 status: std::sync::Mutex<RunStatus>,
272 footer: Arc<FooterComponent>,
274 status_container: Arc<Container>,
277 chat_container: Arc<Container>,
279 loader: Arc<Loader>,
281 last_assistant_text: std::sync::Mutex<String>,
284 active_selector: std::sync::Mutex<Option<(Arc<SelectList>, SelectorKind)>>,
288 autocomplete: AutocompleteManager,
291 autocomplete_container: Arc<Container>,
294 theme_manager: Arc<ThemeManager>,
298 tui: Option<Arc<TuiAltScreen>>,
302 current_model_id: std::sync::Mutex<String>,
306 show_images: std::sync::Mutex<bool>,
310}
311
312impl TuiState {
313 fn set_status(&self, status: RunStatus) {
314 *self.status.lock().unwrap() = status;
315 match status {
316 RunStatus::Working => {
317 self.footer.set_status("Working…");
318 if let Some(tui) = &self.tui {
321 tui.set_title("rpi — working");
322 }
323 self.status_container.clear();
324 self.loader.start();
325 self.status_container.add_child(self.loader.clone());
326 }
327 RunStatus::Aborting => {
328 self.footer.set_status("Aborting…");
329 }
330 RunStatus::Idle => {
331 self.footer.set_status("");
332 if let Some(tui) = &self.tui {
333 tui.set_title("rpi");
334 }
335 self.loader.stop();
336 self.status_container.clear();
337 }
338 }
339 }
340
341 fn selector_open(&self) -> bool {
343 self.active_selector.lock().unwrap().is_some()
344 }
345
346 fn remember_tool(&self, comp: Arc<ToolExecutionComponent>) {
349 *self.last_tool_comp.lock().unwrap() = Some(comp);
350 }
351
352 fn toggle_expand_last_tool(&self) -> bool {
358 if let Some(comp) = self.last_tool_comp.lock().unwrap().as_ref() {
359 let cur = comp.is_expanded();
360 comp.set_expanded(!cur);
361 true
362 } else {
363 false
364 }
365 }
366
367 fn current_model_id(&self) -> String {
369 self.current_model_id.lock().unwrap().clone()
370 }
371
372 fn set_current_model(&self, model: &rpi_ai::Model) {
375 *self.current_model_id.lock().unwrap() = model.id.clone();
376 self.footer.set_model(&short_model_name(&model.id));
377 }
378}
379
380pub async fn interactive_tui(
398 harness: &AgentHarness,
399 event_rx: Option<broadcast::Receiver<AgentEvent>>,
400 args: &Args,
401 model_catalog: Vec<rpi_ai::Model>,
402 initial: Option<String>,
403 extra_messages: &[String],
404 theme: Option<&str>,
405) -> i32 {
406 let lane: Arc<dyn AgentLane> = harness.lane("main");
407
408 let lane_model_id = lane
412 .get_model()
413 .await
414 .map(|m| m.id)
415 .unwrap_or_default();
416 let model_name = short_model_name(&lane_model_id);
417
418 let cwd = std::env::current_dir()
420 .map(|p| p.to_path_buf())
421 .unwrap_or_else(|_| std::path::PathBuf::from("."));
422
423 let (tx, rx) = channel::<TuiMessage>();
425
426 let terminal = Box::new(ProcessTerminal::new());
428 let tui = Arc::new(TuiAltScreen::new(terminal, true, None));
429
430 let chat_container = Arc::new(Container::new());
431 add_welcome_message(&chat_container);
432
433 crate::extras::maybe_first_time_setup(&chat_container);
439
440 let document_container = Arc::new(Container::new());
443 document_container.add_child(chat_container.clone());
444
445 let scroll_view = Arc::new(ScrollView::new(
446 document_container.clone(),
447 ScrollViewOptions {
448 follow: FollowMode::End,
449 primary: true,
450 ..Default::default()
451 },
452 ));
453
454 let editor = Arc::new(Editor::new(
459 EditorOptions {
460 padding_x: 1,
461 ..Default::default()
462 },
463 EditorStyle::default(),
464 Arc::new(rpi_tui::Keybindings::new()),
465 ));
466
467 let footer = Arc::new(FooterComponent::new());
469 footer.set_model(&model_name);
470 footer.set_hints("Enter: Send | Shift+Enter: New line | Ctrl+C: Abort/Exit | Esc: Abort | Ctrl+L: Model | Ctrl+M: Cycle | Ctrl+T: Expand tool | /help");
471
472 let status_container = Arc::new(Container::new());
473 let loader = Arc::new(Loader::with_text("Working…"));
474
475 let autocomplete = AutocompleteManager::new();
477 {
478 let mut combined = CombinedAutocompleteProvider::new();
479 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
480 v1_slash_commands(),
481 )));
482 combined.add_provider(Arc::new(FilePathAutocompleteProvider::with_root(cwd.clone())));
483 autocomplete.set_provider(Arc::new(combined));
484 }
485 let autocomplete_container = Arc::new(Container::new());
486
487 let state = Arc::new(TuiState {
488 current_assistant: std::sync::Mutex::new(None),
489 tool_components: std::sync::Mutex::new(HashMap::new()),
490 bash_components: std::sync::Mutex::new(HashMap::new()),
491 last_tool_comp: std::sync::Mutex::new(None),
492 status: std::sync::Mutex::new(RunStatus::Idle),
493 footer: footer.clone(),
494 status_container: status_container.clone(),
495 chat_container: chat_container.clone(),
496 loader: loader.clone(),
497 last_assistant_text: std::sync::Mutex::new(String::new()),
498 active_selector: std::sync::Mutex::new(None),
499 autocomplete,
500 autocomplete_container: autocomplete_container.clone(),
501 theme_manager: Arc::new(ThemeManager::new()),
502 tui: Some(tui.clone()),
503 current_model_id: std::sync::Mutex::new(lane_model_id.clone()),
504 show_images: std::sync::Mutex::new(true),
505 });
506
507 if let Some(theme_name) = theme {
512 let preset = match theme_name {
513 "light" => Some(ThemePreset::Light),
514 "monochrome" => Some(ThemePreset::Monochrome),
515 "dark" => Some(ThemePreset::Dark),
516 _ => None,
517 };
518 if let Some(preset) = preset {
519 state.theme_manager.apply_preset(preset);
520 }
521 }
522
523 let model_catalog_arc = Arc::new(model_catalog.clone());
526 let lane_model_id = lane
527 .get_model()
528 .await
529 .map(|m| m.id)
530 .unwrap_or_default();
531
532 let editor_container = Arc::new(Container::new());
543 editor_container.add_child(editor.clone());
544
545 let dock = Arc::new(VStack::from_children(vec![
546 StackChild::Entry(StackEntry::new(status_container.clone())),
547 StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
548 StackChild::Entry(
549 StackEntry::new(editor_container.clone())
550 .shrink(0)
551 .min_size(3),
552 ),
553 StackChild::Entry(StackEntry::new(footer.clone())),
554 ]));
555
556 let root = VStack::from_children(vec![
557 StackChild::Entry(
558 StackEntry::new(scroll_view.clone())
559 .basis(0)
560 .grow(1)
561 .shrink(1)
562 .min_size(1),
563 ),
564 StackChild::Entry(StackEntry::new(dock).shrink(1)),
565 ]);
566
567 tui.set_layout_root(Some(Arc::new(root)));
568 tui.set_focus(Some(editor.clone()));
569 editor.set_focused(true);
570
571 let chat_for_cb = chat_container.clone();
573 let tui_for_cb = tui.clone();
574 let tx_for_cb = tx.clone();
575 let state_for_cb = state.clone();
576 let editor_for_cb = editor.clone();
577 let lane_for_cb = lane.clone();
578 let editor_container_for_cb = editor_container.clone();
581 let model_catalog_for_cb = model_catalog_arc.clone();
582 let lane_model_id_for_cb = lane_model_id.clone();
583 let cwd_for_cb = cwd.clone();
584 editor.on_submit(Arc::new(move |text: &str| {
585 let text = text.trim();
586 if text.is_empty() {
587 return;
588 }
589
590 if text.starts_with('/') {
591 match handle_slash_command(text) {
592 SlashCommandResult::Exit => {
593 let _ = tx_for_cb.send(TuiMessage::Exit);
594 }
595 SlashCommandResult::ClearChat => {
596 let _ = tx_for_cb.send(TuiMessage::ClearChat);
597 }
598 SlashCommandResult::Help => {
599 add_help_message(&chat_for_cb);
600 tui_for_cb.request_render(false);
601 }
602 SlashCommandResult::Version => {
603 add_version_message(&chat_for_cb);
604 tui_for_cb.request_render(false);
605 }
606 SlashCommandResult::Hotkeys => {
607 add_hotkeys_message(&chat_for_cb);
608 tui_for_cb.request_render(false);
609 }
610 SlashCommandResult::SelectModel => {
611 open_model_selector(
612 &state_for_cb,
613 &editor_container_for_cb,
614 &editor_for_cb,
615 &tui_for_cb,
616 &model_catalog_for_cb,
617 &lane_for_cb,
618 &lane_model_id_for_cb,
619 &chat_for_cb,
620 );
621 }
622 SlashCommandResult::SelectThinking => {
623 open_thinking_selector(
624 &state_for_cb,
625 &editor_container_for_cb,
626 &editor_for_cb,
627 &tui_for_cb,
628 &lane_for_cb,
629 &model_catalog_for_cb,
630 &lane_model_id_for_cb,
631 &chat_for_cb,
632 );
633 }
634 SlashCommandResult::SelectTools => {
635 open_tools_selector(
636 &state_for_cb,
637 &editor_container_for_cb,
638 &editor_for_cb,
639 &tui_for_cb,
640 &lane_for_cb,
641 &chat_for_cb,
642 );
643 }
644 SlashCommandResult::SelectImages => {
645 open_images_selector(
646 &state_for_cb,
647 &editor_container_for_cb,
648 &editor_for_cb,
649 &tui_for_cb,
650 &chat_for_cb,
651 );
652 }
653 SlashCommandResult::Armin => {
654 crate::extras::add_armin(&chat_for_cb);
655 tui_for_cb.request_render(false);
656 }
657 SlashCommandResult::Earendil => {
658 crate::extras::add_earendil(&chat_for_cb);
659 tui_for_cb.request_render(false);
660 }
661 SlashCommandResult::SelectSession => {
662 open_session_selector(
663 &state_for_cb,
664 &editor_container_for_cb,
665 &editor_for_cb,
666 &tui_for_cb,
667 &cwd_for_cb,
668 );
669 }
670 SlashCommandResult::SelectTheme => {
671 open_theme_selector(
672 &state_for_cb,
673 &editor_container_for_cb,
674 &editor_for_cb,
675 &tui_for_cb,
676 );
677 }
678 SlashCommandResult::Compact => {
679 let _ = tx_for_cb.send(TuiMessage::Compact);
680 }
681 SlashCommandResult::Copy => {
682 let _ = tx_for_cb.send(TuiMessage::Copy);
683 }
684 SlashCommandResult::Unsupported(cmd) => {
685 add_note_message(
686 &chat_for_cb,
687 &format!("{cmd} is not supported in v1."),
688 );
689 tui_for_cb.request_render(false);
690 }
691 SlashCommandResult::Unknown => {
692 add_error_message(
693 &chat_for_cb,
694 &format!("Unknown command: {text}. Type /help for available commands."),
695 );
696 tui_for_cb.request_render(false);
697 }
698 SlashCommandResult::SendMessage(msg) => {
699 add_user_message(&chat_for_cb, &msg);
700 tui_for_cb.request_render(false);
701 let _ = tx_for_cb.send(TuiMessage::UserInput(msg));
702 }
703 }
704 return;
705 }
706
707 add_user_message(&chat_for_cb, text);
708 tui_for_cb.request_render(false);
709 let _ = tx_for_cb.send(TuiMessage::UserInput(text.to_string()));
710 }));
711
712 tui.start_readerless();
713
714 let drain_handle = if let Some(rx) = event_rx {
716 let tui_drain = tui.clone();
717 let state_drain = state.clone();
718 let chat_drain = chat_container.clone();
719 Some(tokio::spawn(async move {
720 drain_agent_events(rx, tui_drain, state_drain, chat_drain).await;
721 }))
722 } else {
723 None
724 };
725
726 let tui_tick = tui.clone();
731 let state_tick = state.clone();
732 let tick_handle = tokio::spawn(async move {
733 let mut interval = tokio::time::interval(std::time::Duration::from_millis(120));
734 interval.tick().await; loop {
736 interval.tick().await;
737 let working = *state_tick.status.lock().unwrap() == RunStatus::Working;
738 if working {
739 tui_tick.request_render(false);
740 }
741 }
742 });
743
744 let running = Arc::new(std::sync::Mutex::new(true));
746 let running_key = running.clone();
747 let tx_for_key = tx.clone();
748 let tui_for_key = tui.clone();
749 let editor_for_key = editor.clone();
750 let editor_container_for_key = editor_container.clone();
751 let scroll_for_key = scroll_view.clone();
752 let lane_for_key = lane.clone();
753 let state_for_key = state.clone();
754 let catalog_for_key = model_catalog_arc.clone();
757 let lane_model_id_for_key = lane_model_id.clone();
758 let chat_for_key = chat_container.clone();
759
760 tokio::task::spawn_blocking(move || {
761 loop {
762 if !*running_key.lock().unwrap() {
763 break;
764 }
765 let Ok(ev) = crossterm::event::read() else {
766 continue;
767 };
768 if let Event::Resize(_cols, _rows) = ev {
773 tui_for_key.refresh_size();
774 continue;
775 }
776 let Event::Key(key) = ev else { continue; };
777 if key.kind != KeyEventKind::Press {
783 continue;
784 }
785
786 if state_for_key.selector_open() {
791 if key.code == KeyCode::Esc {
793 close_selector(
794 &state_for_key,
795 &editor_container_for_key,
796 &editor_for_key,
797 &tui_for_key,
798 );
799 continue;
800 }
801 let (selector, _kind) = state_for_key
802 .active_selector
803 .lock()
804 .unwrap()
805 .clone()
806 .expect("selector_open guaranteed Some");
807 selector.handle_key(key);
808 tui_for_key.request_render(false);
809 continue;
810 }
811
812 if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c') {
814 let status = *state_for_key.status.lock().unwrap();
815 if status == RunStatus::Working {
816 state_for_key.set_status(RunStatus::Aborting);
817 let lane = lane_for_key.clone();
818 tokio::spawn(async move {
819 let _ = lane.abort().await;
820 });
821 } else {
822 let _ = tx_for_key.send(TuiMessage::Exit);
823 }
824 continue;
825 }
826
827 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Esc {
832 let status = *state_for_key.status.lock().unwrap();
833 if status == RunStatus::Working {
834 state_for_key.set_status(RunStatus::Aborting);
835 let lane = lane_for_key.clone();
836 tokio::spawn(async move {
837 let _ = lane.abort().await;
838 });
839 continue;
840 }
841 }
842
843 if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('t') {
848 state_for_key.toggle_expand_last_tool();
849 tui_for_key.request_render(false);
850 continue;
851 }
852
853 if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('m') {
859 if let Some(next) = cycle_next_model(&catalog_for_key, &state_for_key.current_model_id()) {
860 state_for_key.set_current_model(&next);
861 let lane = lane_for_key.clone();
862 tokio::spawn(async move {
863 let _ = lane.set_model(next).await;
864 });
865 tui_for_key.request_render(false);
866 }
867 continue;
868 }
869
870 if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('l') {
875 open_model_selector(
876 &state_for_key,
877 &editor_container_for_key,
878 &editor_for_key,
879 &tui_for_key,
880 &catalog_for_key,
881 &lane_for_key,
882 &lane_model_id_for_key,
883 &chat_for_key,
884 );
885 continue;
886 }
887
888 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Tab {
890 if accept_top_suggestion(&state_for_key, &editor_for_key) {
891 tui_for_key.request_render(false);
892 }
893 continue;
894 }
895
896 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageUp {
898 scroll_for_key.scroll_by(-10);
899 tui_for_key.request_render(false);
900 continue;
901 }
902 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageDown {
903 scroll_for_key.scroll_by(10);
904 tui_for_key.request_render(false);
905 continue;
906 }
907
908 editor_for_key.handle_key(key);
910 refresh_autocomplete(&state_for_key, &editor_for_key);
911 tui_for_key.request_render(false);
912 }
913 });
914
915 let mut prompts: Vec<String> = Vec::new();
917 if let Some(init) = initial {
918 prompts.push(init);
919 }
920 for m in extra_messages {
921 prompts.push(m.clone());
922 }
923 for prompt in prompts {
924 if !*running.lock().unwrap() {
925 break;
926 }
927 add_user_message(&chat_container, &prompt);
928 tui.request_render(false);
929 run_prompt_streaming(&lane, &prompt, &tui, &state, drain_handle.is_some()).await;
930 }
931
932 loop {
934 if !*running.lock().unwrap() {
935 break;
936 }
937 match rx.try_recv() {
938 Ok(TuiMessage::UserInput(prompt)) => {
939 run_prompt_streaming(&lane, &prompt, &tui, &state, drain_handle.is_some()).await;
940 }
941 Ok(TuiMessage::ClearChat) => {
942 chat_container.clear();
943 add_welcome_message(&chat_container);
944 tui.request_render(false);
945 }
946 Ok(TuiMessage::Compact) => {
947 run_compact(&lane, &tui, &state).await;
948 }
949 Ok(TuiMessage::Copy) => {
950 copy_last_assistant(&state, &chat_container);
951 tui.request_render(false);
952 }
953 Ok(TuiMessage::Exit) => {
954 *running.lock().unwrap() = false;
955 break;
956 }
957 Err(std::sync::mpsc::TryRecvError::Empty) => {
958 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
959 }
960 Err(std::sync::mpsc::TryRecvError::Disconnected) => break,
961 }
962 }
963
964 tick_handle.abort();
966 if let Some(handle) = drain_handle {
967 handle.abort();
968 }
969 tui.stop(Default::default());
970 println!("\nGoodbye!");
971 let _ = args;
972
973 0
974}
975
976async fn run_prompt_streaming(
985 lane: &Arc<dyn AgentLane>,
986 prompt: &str,
987 tui: &Arc<TuiAltScreen>,
988 state: &Arc<TuiState>,
989 streaming: bool,
990) {
991 state.set_status(RunStatus::Working);
993 tui.request_render(false);
994
995 let outcome = lane.prompt_text(prompt, Vec::new()).await;
996
997 {
1001 let mut cur = state.current_assistant.lock().unwrap();
1002 if let Some(comp) = cur.take() {
1003 comp.set_streaming(false);
1004 }
1005 }
1006
1007 state.set_status(RunStatus::Idle);
1008
1009 match outcome {
1010 Ok(result) => match &result.outcome {
1011 HarnessRunOutcome::Failed { error, final_message, .. } => {
1012 let already_rendered = final_message.is_some();
1016 if !already_rendered {
1017 let msg = final_message
1018 .as_ref()
1019 .and_then(|m| m.error_message.clone())
1020 .unwrap_or_else(|| format!("{error:?}"));
1021 add_error_message(&state.chat_container, &msg);
1022 }
1023 }
1024 HarnessRunOutcome::Suspended { .. } => {
1025 add_error_message(
1026 &state.chat_container,
1027 "Run suspended (deferred) — resume is not supported in v1.",
1028 );
1029 }
1030 HarnessRunOutcome::Aborted { final_message, .. } => {
1031 if !streaming {
1034 add_error_message(&state.chat_container, "Request aborted.");
1035 let _ = final_message; }
1037 }
1038 HarnessRunOutcome::Completed { final_message, .. } => {
1039 if !streaming {
1040 let text = assistant_text(final_message);
1041 if !text.is_empty() {
1042 add_assistant_message_blocking(&state.chat_container, &text);
1043 *state.last_assistant_text.lock().unwrap() = text;
1044 }
1045 }
1046 }
1047 },
1048 Err(e) => {
1049 add_error_message(&state.chat_container, &e.to_string());
1050 }
1051 }
1052
1053 tui.request_render(false);
1054}
1055
1056async fn run_compact(lane: &Arc<dyn AgentLane>, tui: &Arc<TuiAltScreen>, state: &Arc<TuiState>) {
1061 state.set_status(RunStatus::Working);
1062 tui.request_render(false);
1063 match lane.compact(None).await {
1064 Ok(_) => {
1065 add_note_message(&state.chat_container, "Conversation compacted.");
1066 }
1067 Err(e) => {
1068 add_error_message(
1069 &state.chat_container,
1070 &format!("Compact failed: {e}"),
1071 );
1072 }
1073 }
1074 state.set_status(RunStatus::Idle);
1075 tui.request_render(false);
1076}
1077
1078fn copy_last_assistant(state: &Arc<TuiState>, chat: &Arc<Container>) {
1082 let text = state.last_assistant_text.lock().unwrap().clone();
1083 if text.is_empty() {
1084 add_note_message(chat, "Nothing to copy yet — no assistant reply captured.");
1085 return;
1086 }
1087 if copy_to_clipboard(&text) {
1088 add_note_message(chat, "Copied last reply to the clipboard.");
1089 } else {
1090 let preview: String = text.chars().take(200).collect();
1093 add_note_message(
1094 chat,
1095 &format!("Clipboard unavailable. Last reply: {preview}{}", if text.chars().count() > 200 { "…" } else { "" }),
1096 );
1097 }
1098}
1099
1100#[cfg(feature = "clipboard")]
1103fn copy_to_clipboard(text: &str) -> bool {
1104 match arboard::Clipboard::new() {
1105 Ok(mut cb) => cb.set_text(text).is_ok(),
1106 Err(_) => false,
1107 }
1108}
1109
1110#[cfg(not(feature = "clipboard"))]
1111fn copy_to_clipboard(_text: &str) -> bool {
1112 false
1113}
1114
1115fn add_assistant_message_blocking(container: &Arc<Container>, text: &str) {
1118 if text.is_empty() {
1119 return;
1120 }
1121 let msg = Arc::new(AssistantMessageComponent::new(AssistantMessageOptions::default()));
1122 msg.update_text(text);
1123 container.add_child(msg);
1124 container.add_child(Arc::new(Spacer::new(1)));
1125}
1126
1127async fn drain_agent_events(
1135 mut rx: broadcast::Receiver<AgentEvent>,
1136 tui: Arc<TuiAltScreen>,
1137 state: Arc<TuiState>,
1138 chat: Arc<Container>,
1139) {
1140 loop {
1141 match rx.recv().await {
1142 Ok(event) => handle_agent_event(event, &tui, &state, &chat).await,
1143 Err(broadcast::error::RecvError::Lagged(_)) => {
1144 continue;
1147 }
1148 Err(broadcast::error::RecvError::Closed) => break,
1149 }
1150 }
1151}
1152
1153async fn handle_agent_event(
1156 event: AgentEvent,
1157 tui: &Arc<TuiAltScreen>,
1158 state: &Arc<TuiState>,
1159 chat: &Arc<Container>,
1160) {
1161 match event {
1162 AgentEvent::AgentStart => {
1163 state.set_status(RunStatus::Working);
1164 tui.request_render(false);
1165 }
1166
1167 AgentEvent::AgentEnd { .. } => {
1168 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
1170 comp.set_streaming(false);
1171 }
1172 state.set_status(RunStatus::Idle);
1173 tui.request_render(false);
1174 }
1175
1176 AgentEvent::TurnStart => {
1177 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
1180 comp.set_streaming(false);
1181 }
1182 }
1183
1184 AgentEvent::TurnEnd { message, tool_results } => {
1185 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
1187 if let AgentMessage::Assistant(a) = &message {
1188 comp.update_blocks(&assistant_blocks(a));
1189 }
1190 comp.set_streaming(false);
1191 }
1192 let tools = state.tool_components.lock().unwrap();
1197 for tr in &tool_results {
1198 if tools.contains_key(&tr.tool_call_id) {
1199 let _ = tr;
1202 }
1203 }
1204 drop(tools);
1205 tui.request_render(false);
1206 }
1207
1208 AgentEvent::MessageStart { message } => match message {
1209 AgentMessage::Assistant(a) => {
1210 let comp = Arc::new(AssistantMessageComponent::new(
1211 AssistantMessageOptions::default(),
1212 ));
1213 comp.set_streaming(true);
1214 comp.update_blocks(&assistant_blocks(&a));
1217 chat.add_child(comp.clone());
1218 chat.add_child(Arc::new(Spacer::new(0)));
1219 *state.current_assistant.lock().unwrap() = Some(comp);
1220 tui.request_render(false);
1221 }
1222 _ => {}
1225 },
1226
1227 AgentEvent::MessageUpdate { message, assistant_message_event } => {
1228 if let AgentMessage::Assistant(a) = &message {
1229 let text = assistant_text(a);
1230 for c in &a.content {
1235 if let Content::ToolCall(tc) = c {
1236 let mut tools = state.tool_components.lock().unwrap();
1237 if !tools.contains_key(&tc.id) {
1238 let comp = Arc::new(ToolExecutionComponent::new(
1239 &tc.name,
1240 &tc.arguments.to_string(),
1241 ));
1242 comp.set_running();
1243 chat.add_child(comp.clone());
1244 tools.insert(tc.id.clone(), comp);
1245 }
1246 }
1247 }
1248 let _ = assistant_message_event; if let Some(comp) = state.current_assistant.lock().unwrap().as_ref() {
1250 comp.update_blocks(&assistant_blocks(a));
1253 }
1254 *state.last_assistant_text.lock().unwrap() = text;
1255 tui.request_render(false);
1256 }
1257 }
1258
1259 AgentEvent::MessageEnd { message } => {
1260 if let AgentMessage::Assistant(a) = &message {
1261 let text = assistant_text(a);
1262 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
1263 comp.update_blocks(&assistant_blocks(a));
1264 comp.set_streaming(false);
1265 }
1266 if !text.is_empty() {
1268 *state.last_assistant_text.lock().unwrap() = text;
1269 }
1270 }
1271 tui.request_render(false);
1272 }
1273
1274 AgentEvent::ToolExecutionStart { tool_call_id, tool_name, args } => {
1275 if tool_name == "bash" {
1276 let command = args
1281 .get("command")
1282 .and_then(|v| v.as_str())
1283 .unwrap_or("")
1284 .to_string();
1285 let comp = Arc::new(BashExecutionComponent::new(command));
1286 chat.add_child(comp.clone());
1287 state
1288 .bash_components
1289 .lock()
1290 .unwrap()
1291 .insert(tool_call_id.clone(), comp);
1292 } else {
1293 let comp = {
1294 let mut tools = state.tool_components.lock().unwrap();
1295 if let Some(existing) = tools.get(&tool_call_id) {
1296 existing.set_args(&args.to_string());
1297 existing.clone()
1298 } else {
1299 let comp = Arc::new(ToolExecutionComponent::new(&tool_name, &args.to_string()));
1300 comp.set_running();
1301 chat.add_child(comp.clone());
1302 tools.insert(tool_call_id.clone(), comp.clone());
1303 comp
1304 }
1305 };
1306 state.remember_tool(comp);
1307 }
1308 tui.request_render(false);
1309 }
1310
1311 AgentEvent::ToolExecutionUpdate { tool_call_id, tool_name, partial_result, .. } => {
1312 if tool_name == "bash" {
1313 let chunk = summarize_tool_result(&partial_result);
1315 if let Some(bash) = state.bash_components.lock().unwrap().get(&tool_call_id) {
1316 bash.append_output(&chunk);
1317 } else {
1318 let comp = Arc::new(BashExecutionComponent::new(""));
1321 comp.append_output(&chunk);
1322 chat.add_child(comp.clone());
1323 state
1324 .bash_components
1325 .lock()
1326 .unwrap()
1327 .insert(tool_call_id.clone(), comp);
1328 }
1329 } else if let Some(comp) = state.tool_components.lock().unwrap().get(&tool_call_id) {
1330 let summary = summarize_tool_result(&partial_result);
1331 comp.set_result(&summary, false);
1332 apply_edit_diff(comp, &tool_name, &partial_result.details, &tui);
1333 state.remember_tool(comp.clone());
1334 } else {
1335 let comp = Arc::new(ToolExecutionComponent::new(&tool_name, ""));
1337 comp.set_running();
1338 comp.set_result(&summarize_tool_result(&partial_result), false);
1339 apply_edit_diff(&comp, &tool_name, &partial_result.details, &tui);
1340 chat.add_child(comp.clone());
1341 state
1342 .tool_components
1343 .lock()
1344 .unwrap()
1345 .insert(tool_call_id.clone(), comp.clone());
1346 state.remember_tool(comp);
1347 }
1348 tui.request_render(false);
1349 }
1350
1351 AgentEvent::ToolExecutionEnd { tool_call_id, tool_name, result, is_error } => {
1352 if tool_name == "bash" {
1353 let bash = state.bash_components.lock().unwrap().remove(&tool_call_id);
1354 if let Some(bash) = bash {
1355 finalize_bash(&bash, &result, is_error);
1356 } else {
1357 let command = result
1360 .details
1361 .get("command")
1362 .and_then(|v| v.as_str())
1363 .unwrap_or("")
1364 .to_string();
1365 let comp = Arc::new(BashExecutionComponent::new(command));
1366 comp.append_output(&summarize_tool_result(&result));
1367 finalize_bash(&comp, &result, is_error);
1368 chat.add_child(comp);
1369 }
1370 } else {
1371 let comp = state.tool_components.lock().unwrap().remove(&tool_call_id);
1372 if let Some(comp) = comp {
1373 comp.set_result(&summarize_tool_result(&result), is_error);
1374 apply_edit_diff(&comp, &tool_name, &result.details, &tui);
1375 } else {
1376 let comp = Arc::new(ToolExecutionComponent::new(&tool_name, ""));
1379 comp.set_result(&summarize_tool_result(&result), is_error);
1380 apply_edit_diff(&comp, &tool_name, &result.details, &tui);
1381 chat.add_child(comp.clone());
1382 state.remember_tool(comp);
1383 }
1384 }
1385 tui.request_render(false);
1386 }
1387 }
1388}
1389
1390fn finalize_bash(comp: &Arc<BashExecutionComponent>, result: &rpi_agent::AgentToolResult, is_error: bool) {
1394 let exit_code = if is_error { Some(1) } else { Some(0) };
1397 let truncated = result
1398 .details
1399 .get("truncation")
1400 .and_then(|t| t.get("truncated"))
1401 .and_then(|v| v.as_bool())
1402 .unwrap_or(false);
1403 let full_output_path = result
1404 .details
1405 .get("full_output_path")
1406 .and_then(|v| v.as_str())
1407 .map(|s| s.to_string());
1408 let truncation = BashTruncation {
1409 truncated,
1410 full_output_path,
1411 };
1412 let cancelled = false; comp.set_complete(exit_code, cancelled, truncation);
1414}
1415
1416fn apply_edit_diff(
1421 comp: &Arc<ToolExecutionComponent>,
1422 tool_name: &str,
1423 details: &serde_json::Value,
1424 tui: &Arc<TuiAltScreen>,
1425) {
1426 if tool_name != "edit" {
1427 return;
1428 }
1429 let Some(diff_text) = details.get("diff").and_then(|v| v.as_str()) else {
1430 return;
1431 };
1432 if diff_text.is_empty() {
1433 return;
1434 }
1435 let width = tui.width();
1436 let lines = render_diff(diff_text, width);
1437 comp.set_diff(lines);
1438}
1439
1440fn summarize_tool_result(result: &rpi_agent::AgentToolResult) -> String {
1443 use rpi_agent::TextContentOrImage;
1444 let mut parts: Vec<String> = Vec::new();
1445 for c in &result.content {
1446 if let TextContentOrImage::Text(t) = c {
1447 parts.push(t.text.clone());
1448 }
1449 }
1450 let joined = parts.join("\n");
1451 let one_line: String = joined.lines().collect::<Vec<_>>().join(" ⏎ ");
1453 if one_line.chars().count() > 200 {
1454 let truncated: String = one_line.chars().take(200).collect();
1455 format!("{truncated}…")
1456 } else {
1457 one_line
1458 }
1459}
1460
1461fn open_selector(
1469 state: &Arc<TuiState>,
1470 editor_container: &Arc<Container>,
1471 editor: &Arc<Editor>,
1472 tui: &Arc<TuiAltScreen>,
1473 list: Arc<SelectList>,
1474 kind: SelectorKind,
1475) {
1476 editor.set_focused(false);
1478 editor_container.clear();
1480 editor_container.add_child(list.clone());
1481 *state.active_selector.lock().unwrap() = Some((list, kind));
1482 tui.request_render(false);
1483}
1484
1485fn close_selector(state: &Arc<TuiState>, editor_container: &Arc<Container>, editor: &Arc<Editor>, tui: &Arc<TuiAltScreen>) {
1488 editor_container.clear();
1489 editor_container.add_child(editor.clone());
1490 editor.set_focused(true);
1491 *state.active_selector.lock().unwrap() = None;
1492 tui.request_render(false);
1493}
1494
1495fn open_model_selector(
1501 state: &Arc<TuiState>,
1502 editor_container: &Arc<Container>,
1503 editor: &Arc<Editor>,
1504 tui: &Arc<TuiAltScreen>,
1505 catalog: &[rpi_ai::Model],
1506 lane: &Arc<dyn AgentLane>,
1507 lane_model_id: &str,
1508 chat: &Arc<Container>,
1509) {
1510 let mut items: Vec<SelectItem> = Vec::new();
1511 for m in catalog {
1512 let label = if m.name.is_empty() { short_model_name(&m.id) } else { m.name.clone() };
1513 let marker = if m.id.eq_ignore_ascii_case(lane_model_id) { " (current)" } else { "" };
1514 items.push(
1515 SelectItem::new(&m.id, &label)
1516 .with_description(&format!("{id}{marker}", id = m.id)),
1517 );
1518 }
1519 if items.is_empty() {
1520 add_note_message(
1521 chat,
1522 "No models in the catalog. Use --model at startup to select one.",
1523 );
1524 tui.request_render(false);
1525 return;
1526 }
1527 let list = Arc::new(SelectList::new(items, 10));
1528
1529 let catalog_arc = catalog.to_vec();
1533 let state_sel = state.clone();
1534 let ec_sel = editor_container.clone();
1535 let editor_sel = editor.clone();
1536 let tui_sel = tui.clone();
1537 let chat_sel = chat.clone();
1538 let lane_sel = lane.clone();
1539 list.on_select(Arc::new(move |item| {
1540 let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
1541 add_note_message(&chat_sel, &format!("Model {} not found in catalog.", item.label));
1542 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
1543 return;
1544 };
1545 state_sel.set_current_model(&model);
1546 let lane = lane_sel.clone();
1547 tokio::spawn(async move {
1548 let _ = lane.set_model(model).await;
1549 });
1550 add_note_message(
1551 &chat_sel,
1552 &format!(
1553 "Model set to {} — applies to the next message.",
1554 short_model_name(&item.value)
1555 ),
1556 );
1557 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
1558 }));
1559 let state_cancel = state.clone();
1560 let ec_cancel = editor_container.clone();
1561 let editor_cancel = editor.clone();
1562 let tui_cancel = tui.clone();
1563 list.on_cancel(Arc::new(move || {
1564 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1565 }));
1566
1567 open_selector(state, editor_container, editor, tui, list, SelectorKind::Model);
1568}
1569
1570fn cycle_next_model(catalog: &[rpi_ai::Model], current_id: &str) -> Option<rpi_ai::Model> {
1575 if catalog.is_empty() {
1576 return None;
1577 }
1578 let idx = catalog
1579 .iter()
1580 .position(|m| m.id.eq_ignore_ascii_case(current_id));
1581 match idx {
1582 Some(i) => {
1583 let next = (i + 1) % catalog.len();
1584 Some(catalog[next].clone())
1585 }
1586 None => Some(catalog[0].clone()),
1587 }
1588}
1589
1590fn open_session_selector(
1595 state: &Arc<TuiState>,
1596 editor_container: &Arc<Container>,
1597 editor: &Arc<Editor>,
1598 tui: &Arc<TuiAltScreen>,
1599 cwd: &std::path::Path,
1600) {
1601 let dir = crate::session::default_session_dir(cwd);
1602 let mut items: Vec<SelectItem> = Vec::new();
1603 if let Ok(entries) = std::fs::read_dir(&dir) {
1604 for entry in entries.flatten() {
1605 let path = entry.path();
1606 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
1607 continue;
1608 }
1609 let stem = path
1610 .file_stem()
1611 .and_then(|s| s.to_str())
1612 .unwrap_or("(unnamed)")
1613 .to_string();
1614 let display = path
1615 .file_name()
1616 .and_then(|s| s.to_str())
1617 .unwrap_or(&stem)
1618 .to_string();
1619 items.push(SelectItem::new(&stem, &display));
1620 }
1621 }
1622 if items.is_empty() {
1623 add_note_message(
1624 &state.chat_container,
1625 "No saved sessions found. Sessions are created automatically in interactive mode.",
1626 );
1627 tui.request_render(false);
1628 return;
1629 }
1630 let list = Arc::new(SelectList::new(items, 10));
1631
1632 let state_sel = state.clone();
1633 let ec_sel = editor_container.clone();
1634 let editor_sel = editor.clone();
1635 let tui_sel = tui.clone();
1636 let chat_sel = state.chat_container.clone();
1637 list.on_select(Arc::new(move |item| {
1638 add_note_message(
1639 &chat_sel,
1640 &format!("Session {} — restore is not implemented in v1.", item.label),
1641 );
1642 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
1643 }));
1644 let state_cancel = state.clone();
1645 let ec_cancel = editor_container.clone();
1646 let editor_cancel = editor.clone();
1647 let tui_cancel = tui.clone();
1648 list.on_cancel(Arc::new(move || {
1649 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1650 }));
1651
1652 open_selector(state, editor_container, editor, tui, list, SelectorKind::Session);
1653}
1654
1655fn open_theme_selector(
1658 state: &Arc<TuiState>,
1659 editor_container: &Arc<Container>,
1660 editor: &Arc<Editor>,
1661 tui: &Arc<TuiAltScreen>,
1662) {
1663 let items = vec![
1664 SelectItem::new("dark", "Dark").with_description("Default dark theme"),
1665 SelectItem::new("light", "Light").with_description("Light background"),
1666 SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
1667 ];
1668 let list = Arc::new(SelectList::new(items, 10));
1669
1670 let state_sel = state.clone();
1671 let ec_sel = editor_container.clone();
1672 let editor_sel = editor.clone();
1673 let tui_sel = tui.clone();
1674 let chat_sel = state.chat_container.clone();
1675 list.on_select(Arc::new(move |item| {
1676 let preset = match item.value.as_str() {
1677 "light" => ThemePreset::Light,
1678 "monochrome" => ThemePreset::Monochrome,
1679 _ => ThemePreset::Dark,
1680 };
1681 state_sel.theme_manager.apply_preset(preset);
1682 add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
1685 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
1686 tui_sel.render_now(true);
1687 }));
1688 let state_cancel = state.clone();
1689 let ec_cancel = editor_container.clone();
1690 let editor_cancel = editor.clone();
1691 let tui_cancel = tui.clone();
1692 list.on_cancel(Arc::new(move || {
1693 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1694 }));
1695
1696 open_selector(state, editor_container, editor, tui, list, SelectorKind::Theme);
1697}
1698
1699fn thinking_level_description(level: rpi_ai::types::ThinkingLevel) -> &'static str {
1706 use rpi_ai::types::ThinkingLevel::*;
1707 match level {
1708 Off => "Off — No reasoning",
1709 Minimal => "Minimal — Brief reasoning (~1k tokens)",
1710 Low => "Low — Light reasoning (~1k tokens)",
1711 Medium => "Medium — Moderate reasoning (~80% of max)",
1712 High => "High — Extensive reasoning (~95% of max)",
1713 Xhigh => "Xhigh — Near-maximal reasoning",
1714 Max => "Max — Maximum reasoning",
1715 }
1716}
1717
1718fn thinking_level_name(level: rpi_ai::types::ThinkingLevel) -> &'static str {
1721 use rpi_ai::types::ThinkingLevel::*;
1722 match level {
1723 Off => "off",
1724 Minimal => "minimal",
1725 Low => "low",
1726 Medium => "medium",
1727 High => "high",
1728 Xhigh => "xhigh",
1729 Max => "max",
1730 }
1731}
1732
1733fn thinking_level_from_name(name: &str) -> Option<rpi_ai::types::ThinkingLevel> {
1736 use rpi_ai::types::ThinkingLevel::*;
1737 match name.to_ascii_lowercase().as_str() {
1738 "off" => Some(Off),
1739 "minimal" => Some(Minimal),
1740 "low" => Some(Low),
1741 "medium" => Some(Medium),
1742 "high" => Some(High),
1743 "xhigh" => Some(Xhigh),
1744 "max" => Some(Max),
1745 _ => None,
1746 }
1747}
1748
1749fn open_thinking_selector(
1759 state: &Arc<TuiState>,
1760 editor_container: &Arc<Container>,
1761 editor: &Arc<Editor>,
1762 tui: &Arc<TuiAltScreen>,
1763 lane: &Arc<dyn AgentLane>,
1764 catalog: &[rpi_ai::Model],
1765 lane_model_id: &str,
1766 chat: &Arc<Container>,
1767) {
1768 let model = catalog
1771 .iter()
1772 .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
1773 let levels: Vec<rpi_ai::types::ThinkingLevel> = model
1774 .map(|m| m.supported_thinking_levels())
1775 .unwrap_or_else(|| {
1776 use rpi_ai::types::ThinkingLevel::*;
1777 vec![Off, Minimal, Low, Medium, High]
1778 });
1779 let mut items: Vec<SelectItem> = Vec::new();
1780 for lvl in &levels {
1781 let name = thinking_level_name(*lvl);
1782 items.push(
1783 SelectItem::new(name, name)
1784 .with_description(thinking_level_description(*lvl)),
1785 );
1786 }
1787 if items.is_empty() {
1788 add_note_message(chat, "This model has no supported thinking levels.");
1789 tui.request_render(false);
1790 return;
1791 }
1792 let list = Arc::new(SelectList::new(items, 10));
1793
1794 let state_sel = state.clone();
1795 let ec_sel = editor_container.clone();
1796 let editor_sel = editor.clone();
1797 let tui_sel = tui.clone();
1798 let chat_sel = chat.clone();
1799 let lane_sel = lane.clone();
1800 list.on_select(Arc::new(move |item| {
1801 let Some(level) = thinking_level_from_name(&item.value) else {
1802 add_note_message(&chat_sel, &format!("Unknown thinking level: {}.", item.label));
1803 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
1804 return;
1805 };
1806 let lane = lane_sel.clone();
1807 let footer_sel = state_sel.footer.clone();
1808 tokio::spawn(async move {
1809 let _ = lane.set_thinking_level(level).await;
1810 });
1811 footer_sel.set_thinking_level(Some(thinking_level_name(level)));
1815 add_note_message(&chat_sel, &format!("Thinking set to {}.", item.label));
1816 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
1817 }));
1818 let state_cancel = state.clone();
1819 let ec_cancel = editor_container.clone();
1820 let editor_cancel = editor.clone();
1821 let tui_cancel = tui.clone();
1822 list.on_cancel(Arc::new(move || {
1823 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1824 }));
1825
1826 open_selector(state, editor_container, editor, tui, list, SelectorKind::Thinking);
1827}
1828
1829fn open_tools_selector(
1835 state: &Arc<TuiState>,
1836 editor_container: &Arc<Container>,
1837 editor: &Arc<Editor>,
1838 tui: &Arc<TuiAltScreen>,
1839 lane: &Arc<dyn AgentLane>,
1840 chat: &Arc<Container>,
1841) {
1842 let active = match tokio::runtime::Handle::try_current() {
1847 Ok(h) => h.block_on(async { lane.get_active_tools().await }).unwrap_or_default(),
1848 Err(_) => Vec::new(),
1849 };
1850 let mut items: Vec<SelectItem> = Vec::new();
1851 for name in crate::session::BUILTIN_TOOL_NAMES {
1852 let on = active.iter().any(|a| a == name);
1853 let label = if on { format!("{name} (on)") } else { (*name).to_string() };
1854 items.push(SelectItem::new(name, &label).with_description("Toggle tool on/off"));
1855 }
1856 let list = Arc::new(SelectList::new(items, 10));
1857
1858 let active_captured = active.clone();
1860 let state_sel = state.clone();
1861 let ec_sel = editor_container.clone();
1862 let editor_sel = editor.clone();
1863 let tui_sel = tui.clone();
1864 let chat_sel = chat.clone();
1865 let lane_sel = lane.clone();
1866 list.on_select(Arc::new(move |item| {
1867 let mut next = active_captured.clone();
1868 if let Some(pos) = next.iter().position(|a| a == &item.value) {
1869 next.remove(pos);
1870 } else {
1871 next.push(item.value.clone());
1872 }
1873 let on = next.iter().any(|a| a == &item.value);
1874 let lane = lane_sel.clone();
1875 let next_clone = next.clone();
1876 tokio::spawn(async move {
1877 let _ = lane.set_active_tools(next_clone).await;
1878 });
1879 let list_str = if next.is_empty() {
1880 "(none)".to_string()
1881 } else {
1882 next.join(", ")
1883 };
1884 add_note_message(
1885 &chat_sel,
1886 &format!(
1887 "{} {} — active tools: {}",
1888 item.value,
1889 if on { "enabled" } else { "disabled" },
1890 list_str
1891 ),
1892 );
1893 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
1894 }));
1895 let state_cancel = state.clone();
1896 let ec_cancel = editor_container.clone();
1897 let editor_cancel = editor.clone();
1898 let tui_cancel = tui.clone();
1899 list.on_cancel(Arc::new(move || {
1900 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1901 }));
1902
1903 open_selector(state, editor_container, editor, tui, list, SelectorKind::Tools);
1904}
1905
1906fn open_images_selector(
1910 state: &Arc<TuiState>,
1911 editor_container: &Arc<Container>,
1912 editor: &Arc<Editor>,
1913 tui: &Arc<TuiAltScreen>,
1914 chat: &Arc<Container>,
1915) {
1916 let current = *state.show_images.lock().unwrap();
1917 let items = vec![
1918 SelectItem::new("yes", "Yes")
1919 .with_description(if current { "Inline images (current)" } else { "Inline images" }),
1920 SelectItem::new("no", "No")
1921 .with_description(if current { "Placeholder only" } else { "Placeholder only (current)" }),
1922 ];
1923 let list = Arc::new(SelectList::new(items, 5));
1924
1925 let state_sel = state.clone();
1926 let ec_sel = editor_container.clone();
1927 let editor_sel = editor.clone();
1928 let tui_sel = tui.clone();
1929 let chat_sel = chat.clone();
1930 list.on_select(Arc::new(move |item| {
1931 let on = item.value == "yes";
1932 *state_sel.show_images.lock().unwrap() = on;
1933 add_note_message(
1934 &chat_sel,
1935 &format!("Inline images {}.", if on { "enabled" } else { "disabled" }),
1936 );
1937 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
1938 }));
1939 let state_cancel = state.clone();
1940 let ec_cancel = editor_container.clone();
1941 let editor_cancel = editor.clone();
1942 let tui_cancel = tui.clone();
1943 list.on_cancel(Arc::new(move || {
1944 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1945 }));
1946
1947 open_selector(state, editor_container, editor, tui, list, SelectorKind::Images);
1948}
1949
1950fn refresh_autocomplete(state: &Arc<TuiState>, editor: &Arc<Editor>) {
1958 let text = editor.get_text();
1959 let (_row, col) = editor.cursor_position();
1960 let cursor = col.min(text.len());
1968 let suggestions = state.autocomplete.get_suggestions(&text, cursor);
1969 render_autocomplete(state, suggestions);
1970}
1971
1972fn render_autocomplete(state: &Arc<TuiState>, suggestions: Option<AutocompleteSuggestions>) {
1974 state.autocomplete_container.clear();
1975 let Some(sugg) = suggestions else {
1976 return;
1977 };
1978 if sugg.items.is_empty() {
1979 return;
1980 }
1981 let accent = state.theme_manager.get().colors.accent;
1984 let muted = state.theme_manager.get().colors.muted;
1985 for (i, item) in sugg.items.iter().take(5).enumerate() {
1986 let prefix = if i == 0 { "→ " } else { " " };
1987 let label = item.display_text();
1988 let line = if i == 0 {
1989 format!("{prefix}{} {}", accent.fg(label), muted.fg(item.description.as_deref().unwrap_or("")))
1990 } else {
1991 format!("{prefix}{} {}", muted.fg(label), muted.fg(item.description.as_deref().unwrap_or("")))
1992 };
1993 state
1994 .autocomplete_container
1995 .add_child(Arc::new(Text::new(line, 1, 0)));
1996 }
1997}
1998
1999fn accept_top_suggestion(state: &Arc<TuiState>, editor: &Arc<Editor>) -> bool {
2003 let text = editor.get_text();
2004 let (_row, col) = editor.cursor_position();
2005 let cursor = col.min(text.len());
2006 let Some(sugg) = state.autocomplete.get_suggestions(&text, cursor) else {
2007 return false;
2008 };
2009 let Some(top) = sugg.items.first() else {
2010 return false;
2011 };
2012 let start = sugg.start.min(text.len());
2016 let end = sugg.end.min(text.len());
2017 let mut replaced = String::with_capacity(text.len() + top.text.len());
2018 replaced.push_str(&text[..start]);
2019 replaced.push_str(&top.text);
2020 if top.insert_space && !replaced.ends_with('/') {
2021 replaced.push(' ');
2022 }
2023 let new_cursor = replaced.len().min(
2026 start + top.text.len()
2027 + if top.insert_space && !top.text.ends_with('/') {
2028 1
2029 } else {
2030 0
2031 },
2032 );
2033 let _ = end;
2034 editor.set_text(&replaced);
2035 editor.set_cursor(0, new_cursor);
2036 state.autocomplete_container.clear();
2037 true
2038}
2039
2040fn add_welcome_message(container: &Arc<Container>) {
2046 container.add_child(Arc::new(Text::new("rpi interactive TUI", 1, 0)));
2047 container.add_child(Arc::new(Spacer::new(1)));
2048 container.add_child(Arc::new(Text::new(
2049 "Type your message and press Enter to send.",
2050 1, 0,
2051 )));
2052 container.add_child(Arc::new(Text::new(
2053 "Ctrl+C: Abort/Exit | Esc: Abort | Enter: Send | Shift+Enter: New line | Tab: Complete | Ctrl+L: Model | Ctrl+M: Cycle | Ctrl+T: Expand tool | /help",
2054 1, 0,
2055 )));
2056 container.add_child(Arc::new(Spacer::new(1)));
2057}
2058
2059fn add_help_message(container: &Arc<Container>) {
2061 container.add_child(Arc::new(Text::new("📚 Available Commands:", 1, 0)));
2062 container.add_child(Arc::new(Spacer::new(1)));
2063 container.add_child(Arc::new(Text::new(" /help, /? — Show this help message", 1, 0)));
2064 container.add_child(Arc::new(Text::new(" /clear, /new — Clear the conversation", 1, 0)));
2065 container.add_child(Arc::new(Text::new(" /exit, /quit, /q — Exit the application", 1, 0)));
2066 container.add_child(Arc::new(Text::new(" /version, /v — Show version information", 1, 0)));
2067 container.add_child(Arc::new(Text::new(" /model, /m — Choose a model (live switch)", 1, 0)));
2068 container.add_child(Arc::new(Text::new(" /thinking, /think — Set reasoning depth (selector)", 1, 0)));
2069 container.add_child(Arc::new(Text::new(" /tools — Toggle built-in tools on/off", 1, 0)));
2070 container.add_child(Arc::new(Text::new(" /images — Toggle inline image rendering", 1, 0)));
2071 container.add_child(Arc::new(Text::new(" /session — List saved sessions", 1, 0)));
2072 container.add_child(Arc::new(Text::new(" /theme — Choose a theme (selector)", 1, 0)));
2073 container.add_child(Arc::new(Text::new(" /compact — Compact the conversation", 1, 0)));
2074 container.add_child(Arc::new(Text::new(" /copy — Copy last reply to clipboard", 1, 0)));
2075 container.add_child(Arc::new(Text::new(" /hotkeys — Show keyboard shortcuts", 1, 0)));
2076 container.add_child(Arc::new(Text::new(" /armin — 🐾 Easter egg", 1, 0)));
2077 container.add_child(Arc::new(Text::new(" /earendil — Earendil announcement", 1, 0)));
2078 container.add_child(Arc::new(Spacer::new(1)));
2079}
2080
2081fn add_version_message(container: &Arc<Container>) {
2083 container.add_child(Arc::new(Text::new("📦 Version Information:", 1, 0)));
2084 container.add_child(Arc::new(Spacer::new(1)));
2085 container.add_child(Arc::new(Text::new(" rpi-cli v0.1.2", 1, 0)));
2086 container.add_child(Arc::new(Text::new(
2087 " Rust implementation of pi coding agent TUI",
2088 1, 0,
2089 )));
2090 container.add_child(Arc::new(Spacer::new(1)));
2091}
2092
2093fn add_hotkeys_message(container: &Arc<Container>) {
2095 container.add_child(Arc::new(Text::new("⌨️ Keyboard Shortcuts:", 1, 0)));
2096 container.add_child(Arc::new(Spacer::new(1)));
2097 container.add_child(Arc::new(Text::new(" Enter — Send message", 1, 0)));
2098 container.add_child(Arc::new(Text::new(" Shift+Enter — New line", 1, 0)));
2099 container.add_child(Arc::new(Text::new(" Tab — Accept autocomplete suggestion", 1, 0)));
2100 container.add_child(Arc::new(Text::new(" Ctrl+A / Ctrl+E — Line start / end", 1, 0)));
2101 container.add_child(Arc::new(Text::new(" Ctrl+K / Ctrl+U — Delete to end / start of line", 1, 0)));
2102 container.add_child(Arc::new(Text::new(" Ctrl+C — Abort a run, or exit when idle", 1, 0)));
2103 container.add_child(Arc::new(Text::new(" Esc — Abort a running prompt", 1, 0)));
2104 container.add_child(Arc::new(Text::new(" Ctrl+L — Open model selector", 1, 0)));
2105 container.add_child(Arc::new(Text::new(" Ctrl+M — Cycle to the next model (live)", 1, 0)));
2106 container.add_child(Arc::new(Text::new(" Ctrl+T — Expand/collapse last tool result", 1, 0)));
2107 container.add_child(Arc::new(Text::new(" PageUp/Down — Scroll transcript", 1, 0)));
2108 container.add_child(Arc::new(Spacer::new(1)));
2109}
2110
2111fn add_user_message(container: &Arc<Container>, text: &str) {
2115 container.add_child(Arc::new(UserMessageComponent::new(text.to_string())));
2116 container.add_child(Arc::new(Spacer::new(0)));
2117}
2118
2119fn add_error_message(container: &Arc<Container>, text: &str) {
2121 container.add_child(Arc::new(Text::new(format!("❌ {text}"), 1, 0)));
2122 container.add_child(Arc::new(Spacer::new(1)));
2123}
2124
2125fn add_note_message(container: &Arc<Container>, text: &str) {
2127 container.add_child(Arc::new(Text::new(format!("ℹ️ {text}"), 1, 0)));
2128 container.add_child(Arc::new(Spacer::new(1)));
2129}
2130
2131pub fn is_tui_supported() -> bool {
2137 std::io::stdout().is_terminal()
2138}
2139
2140#[allow(unused_imports)]
2142use rpi_tui::Color as _Color;
2143
2144#[cfg(test)]
2145mod tests {
2146 use super::*;
2147 use rpi_tui::Component;
2148
2149 #[test]
2150 fn test_layout_renders_welcome_message() {
2151 let chat = Arc::new(Container::new());
2152 add_welcome_message(&chat);
2153
2154 let scroll = Arc::new(ScrollView::new(
2155 chat.clone(),
2156 ScrollViewOptions {
2157 follow: FollowMode::End,
2158 primary: true,
2159 ..Default::default()
2160 },
2161 ));
2162
2163 let editor = Arc::new(Editor::new(
2164 EditorOptions {
2165 padding_x: 1,
2166 ..Default::default()
2167 },
2168 EditorStyle::default(),
2169 Arc::new(rpi_tui::Keybindings::new()),
2170 ));
2171 let dock = Arc::new(Container::new());
2172 dock.add_child(editor);
2173
2174 let footer = Arc::new(FooterComponent::new());
2175
2176 let root = VStack::from_children(vec![
2177 StackChild::Entry(StackEntry::new(scroll.clone()).grow(1).min_size(1)),
2178 StackChild::Entry(StackEntry::new(dock)),
2179 StackChild::Entry(StackEntry::new(footer)),
2180 ]);
2181
2182 let frame = rpi_tui::render_layout_frame(Arc::new(root), 80, 24);
2183
2184 let all: String = frame.lines.join("\n");
2185 assert!(all.contains("rpi interactive"), "Welcome message not found. Rendered: {}", all);
2186 assert!(all.contains("Type your message"), "Help text not found. Rendered: {}", all);
2187 }
2188
2189 #[test]
2190 fn test_chat_container_has_welcome_content() {
2191 let chat = Arc::new(Container::new());
2192 add_welcome_message(&chat);
2193
2194 let lines = chat.render(80);
2195 let all: String = lines.join("\n");
2196 assert!(all.contains("rpi interactive"), "Welcome message not in chat container: {:?}", lines);
2197 }
2198
2199 #[test]
2200 fn test_slash_command_dispatch() {
2201 assert!(matches!(handle_slash_command("/help"), SlashCommandResult::Help));
2202 assert!(matches!(handle_slash_command("/clear"), SlashCommandResult::ClearChat));
2203 assert!(matches!(handle_slash_command("/q"), SlashCommandResult::Exit));
2204 assert!(matches!(handle_slash_command("/hotkeys"), SlashCommandResult::Hotkeys));
2205 assert!(matches!(
2206 handle_slash_command("/model"),
2207 SlashCommandResult::SelectModel
2208 ));
2209 assert!(matches!(
2210 handle_slash_command("/theme"),
2211 SlashCommandResult::SelectTheme
2212 ));
2213 assert!(matches!(handle_slash_command("/session"), SlashCommandResult::SelectSession));
2214 assert!(matches!(handle_slash_command("/compact"), SlashCommandResult::Compact));
2215 assert!(matches!(handle_slash_command("/copy"), SlashCommandResult::Copy));
2216 assert!(matches!(
2217 handle_slash_command("/thinking"),
2218 SlashCommandResult::SelectThinking
2219 ));
2220 assert!(matches!(
2221 handle_slash_command("/think"),
2222 SlashCommandResult::SelectThinking
2223 ));
2224 assert!(matches!(
2225 handle_slash_command("/tools"),
2226 SlashCommandResult::SelectTools
2227 ));
2228 assert!(matches!(
2229 handle_slash_command("/images"),
2230 SlashCommandResult::SelectImages
2231 ));
2232 assert!(matches!(handle_slash_command("/armin"), SlashCommandResult::Armin));
2233 assert!(matches!(handle_slash_command("/earendil"), SlashCommandResult::Earendil));
2234 assert!(matches!(
2235 handle_slash_command("/settings"),
2236 SlashCommandResult::Unsupported(_)
2237 ));
2238 assert!(matches!(handle_slash_command("/nope"), SlashCommandResult::Unknown));
2239 assert!(matches!(
2242 handle_slash_command(""),
2243 SlashCommandResult::SendMessage(_)
2244 ));
2245 }
2246
2247 #[test]
2248 fn test_v1_slash_commands_cover_dispatcher() {
2249 let cmds = v1_slash_commands();
2253 let names: Vec<&str> = cmds.iter().map(|c| c.name.as_str()).collect();
2254 for recognized in ["/help", "/clear", "/new", "/exit", "/quit", "/version",
2255 "/model", "/session", "/theme", "/compact", "/copy", "/hotkeys"]
2256 {
2257 assert!(names.contains(&recognized), "{recognized} missing from autocomplete list");
2258 }
2259 }
2260
2261 #[test]
2262 fn test_agent_event_mapping_creates_assistant_and_tool() {
2263 use rpi_ai::types::{StopReason, TextContent, TextContentType, ThinkingContent, ThinkingContentType, ToolCall, ToolCallType, Usage};
2266
2267 let state = Arc::new(TuiState {
2268 current_assistant: std::sync::Mutex::new(None),
2269 tool_components: std::sync::Mutex::new(HashMap::new()),
2270 bash_components: std::sync::Mutex::new(HashMap::new()),
2271 last_tool_comp: std::sync::Mutex::new(None),
2272 status: std::sync::Mutex::new(RunStatus::Idle),
2273 footer: Arc::new(FooterComponent::new()),
2274 status_container: Arc::new(Container::new()),
2275 chat_container: Arc::new(Container::new()),
2276 loader: Arc::new(Loader::new()),
2277 last_assistant_text: std::sync::Mutex::new(String::new()),
2278 active_selector: std::sync::Mutex::new(None),
2279 autocomplete: AutocompleteManager::new(),
2280 autocomplete_container: Arc::new(Container::new()),
2281 theme_manager: Arc::new(ThemeManager::new()),
2282 tui: None,
2283 current_model_id: std::sync::Mutex::new(String::new()),
2284 show_images: std::sync::Mutex::new(true),
2285 });
2286
2287 let assistant = AssistantMessage {
2291 role: rpi_ai::types::AssistantRole,
2292 content: vec![
2293 Content::Thinking(ThinkingContent {
2294 kind: ThinkingContentType,
2295 thinking: "Reasoning about the reply.".into(),
2296 thinking_signature: None,
2297 redacted: false,
2298 }),
2299 Content::Text(TextContent {
2300 kind: TextContentType,
2301 text: "Hello.".into(),
2302 text_signature: None,
2303 }),
2304 Content::ToolCall(ToolCall {
2305 kind: ToolCallType,
2306 id: "tc1".into(),
2307 name: "bash".into(),
2308 arguments: serde_json::json!({"command": "echo hi"}),
2309 thought_signature: None,
2310 namespace: None,
2311 }),
2312 ],
2313 api: rpi_ai::Api::AnthropicMessages,
2314 provider: "anthropic".into(),
2315 model: "claude-sonnet-5".into(),
2316 response_model: None,
2317 response_id: None,
2318 usage: Usage::zero(),
2319 stop_reason: StopReason::Stop,
2320 deferred: None,
2321 error_message: None,
2322 raw_stop_reason: None,
2323 end_turn: None,
2324 timestamp: 0,
2325 };
2326
2327 let comp = Arc::new(AssistantMessageComponent::new(AssistantMessageOptions::default()));
2330 comp.set_streaming(true);
2331 comp.update_blocks(&assistant_blocks(&assistant));
2332 let chat = Arc::new(Container::new());
2333 chat.add_child(comp.clone());
2334 *state.current_assistant.lock().unwrap() = Some(comp);
2335
2336 for c in &assistant.content {
2338 if let Content::ToolCall(tc) = c {
2339 let mut tools = state.tool_components.lock().unwrap();
2340 if !tools.contains_key(&tc.id) {
2341 let tc_comp = Arc::new(ToolExecutionComponent::new(
2342 &tc.name,
2343 &tc.arguments.to_string(),
2344 ));
2345 tc_comp.set_running();
2346 chat.add_child(tc_comp.clone());
2347 tools.insert(tc.id.clone(), tc_comp);
2348 }
2349 }
2350 }
2351
2352 let rendered = chat.render(80);
2356 let joined: String = rendered.join("\n");
2357 assert!(joined.contains("Hello."), "assistant text not rendered: {joined}");
2358 assert!(
2359 joined.contains("Reasoning about the reply."),
2360 "thinking block not rendered: {joined}"
2361 );
2362 assert_eq!(state.tool_components.lock().unwrap().len(), 1);
2363 assert!(state.current_assistant.lock().unwrap().is_some());
2364
2365 let ended = state.tool_components.lock().unwrap().remove("tc1").unwrap();
2367 ended.set_result("hi", false);
2368 assert!(state.tool_components.lock().unwrap().is_empty());
2369 }
2370
2371 #[test]
2372 fn test_short_model_name() {
2373 assert_eq!(short_model_name("anthropic:claude-sonnet-5"), "claude-sonnet-5");
2374 assert_eq!(short_model_name("claude-sonnet-5"), "claude-sonnet-5");
2375 }
2376
2377 #[test]
2378 fn test_cycle_next_model_wraps_around() {
2379 use rpi_ai::{Api, Model};
2380 let mk = |id: &str| {
2381 Model::new(id, id, Api::AnthropicMessages, "anthropic", "https://api.anthropic.com")
2382 };
2383 let catalog = [mk("a"), mk("b"), mk("c")];
2384 assert_eq!(cycle_next_model(&catalog, "a").unwrap().id, "b");
2386 assert_eq!(cycle_next_model(&catalog, "c").unwrap().id, "a");
2387 assert_eq!(cycle_next_model(&catalog, "zzz").unwrap().id, "a");
2389 let empty: Vec<Model> = vec![];
2391 assert!(cycle_next_model(&empty, "a").is_none());
2392 }
2393
2394 #[test]
2395 fn test_autocomplete_slash_suggestions_render() {
2396 let state = Arc::new(TuiState {
2399 current_assistant: std::sync::Mutex::new(None),
2400 tool_components: std::sync::Mutex::new(HashMap::new()),
2401 bash_components: std::sync::Mutex::new(HashMap::new()),
2402 last_tool_comp: std::sync::Mutex::new(None),
2403 status: std::sync::Mutex::new(RunStatus::Idle),
2404 footer: Arc::new(FooterComponent::new()),
2405 status_container: Arc::new(Container::new()),
2406 chat_container: Arc::new(Container::new()),
2407 loader: Arc::new(Loader::new()),
2408 last_assistant_text: std::sync::Mutex::new(String::new()),
2409 active_selector: std::sync::Mutex::new(None),
2410 autocomplete: AutocompleteManager::new(),
2411 autocomplete_container: Arc::new(Container::new()),
2412 theme_manager: Arc::new(ThemeManager::new()),
2413 tui: None,
2414 current_model_id: std::sync::Mutex::new(String::new()),
2415 show_images: std::sync::Mutex::new(true),
2416 });
2417 {
2418 let mut combined = CombinedAutocompleteProvider::new();
2419 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
2420 v1_slash_commands(),
2421 )));
2422 state.autocomplete.set_provider(Arc::new(combined));
2423 }
2424
2425 let editor = Arc::new(Editor::simple());
2426 editor.set_text("/he");
2427 editor.set_cursor(0, 3);
2428 refresh_autocomplete(&state, &editor);
2429 let lines = state.autocomplete_container.render(80);
2430 let joined: String = lines.join("\n");
2431 assert!(joined.contains("/help"), "slash suggestions not rendered: {joined}");
2432
2433 editor.set_text("hello");
2435 editor.set_cursor(0, 5);
2436 refresh_autocomplete(&state, &editor);
2437 assert!(state.autocomplete_container.render(80).is_empty());
2438 }
2439
2440 #[test]
2441 fn test_select_list_swap_restores_editor() {
2442 let state = Arc::new(TuiState {
2446 current_assistant: std::sync::Mutex::new(None),
2447 tool_components: std::sync::Mutex::new(HashMap::new()),
2448 bash_components: std::sync::Mutex::new(HashMap::new()),
2449 last_tool_comp: std::sync::Mutex::new(None),
2450 status: std::sync::Mutex::new(RunStatus::Idle),
2451 footer: Arc::new(FooterComponent::new()),
2452 status_container: Arc::new(Container::new()),
2453 chat_container: Arc::new(Container::new()),
2454 loader: Arc::new(Loader::new()),
2455 last_assistant_text: std::sync::Mutex::new(String::new()),
2456 active_selector: std::sync::Mutex::new(None),
2457 autocomplete: AutocompleteManager::new(),
2458 autocomplete_container: Arc::new(Container::new()),
2459 theme_manager: Arc::new(ThemeManager::new()),
2460 tui: None,
2461 current_model_id: std::sync::Mutex::new(String::new()),
2462 show_images: std::sync::Mutex::new(true),
2463 });
2464 let editor_container = Arc::new(Container::new());
2465 let editor = Arc::new(Editor::simple());
2466 editor_container.add_child(editor.clone());
2467 assert!(!state.selector_open());
2468
2469 let tui_terminal = Box::new(ProcessTerminal::new());
2470 let tui = Arc::new(TuiAltScreen::new(tui_terminal, true, None));
2471 let list = Arc::new(SelectList::new(
2472 vec![SelectItem::new("a", "A"), SelectItem::new("b", "B")],
2473 5,
2474 ));
2475 open_selector(&state, &editor_container, &editor, &tui, list, SelectorKind::Theme);
2476 assert!(state.selector_open());
2477 assert_eq!(editor_container.child_count(), 1);
2479
2480 close_selector(&state, &editor_container, &editor, &tui);
2481 assert!(!state.selector_open());
2482 assert_eq!(editor_container.child_count(), 1);
2484 }
2485
2486 #[test]
2487 fn test_accept_top_suggestion_replaces_prefix() {
2488 let state = Arc::new(TuiState {
2490 current_assistant: std::sync::Mutex::new(None),
2491 tool_components: std::sync::Mutex::new(HashMap::new()),
2492 bash_components: std::sync::Mutex::new(HashMap::new()),
2493 last_tool_comp: std::sync::Mutex::new(None),
2494 status: std::sync::Mutex::new(RunStatus::Idle),
2495 footer: Arc::new(FooterComponent::new()),
2496 status_container: Arc::new(Container::new()),
2497 chat_container: Arc::new(Container::new()),
2498 loader: Arc::new(Loader::new()),
2499 last_assistant_text: std::sync::Mutex::new(String::new()),
2500 active_selector: std::sync::Mutex::new(None),
2501 autocomplete: AutocompleteManager::new(),
2502 autocomplete_container: Arc::new(Container::new()),
2503 theme_manager: Arc::new(ThemeManager::new()),
2504 tui: None,
2505 current_model_id: std::sync::Mutex::new(String::new()),
2506 show_images: std::sync::Mutex::new(true),
2507 });
2508 {
2509 let mut combined = CombinedAutocompleteProvider::new();
2510 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
2511 v1_slash_commands(),
2512 )));
2513 state.autocomplete.set_provider(Arc::new(combined));
2514 }
2515 let editor = Arc::new(Editor::simple());
2516 editor.set_text("/he");
2517 editor.set_cursor(0, 3);
2518 refresh_autocomplete(&state, &editor);
2519 let accepted = accept_top_suggestion(&state, &editor);
2520 assert!(accepted, "should accept the top suggestion");
2521 let text = editor.get_text();
2522 assert!(
2523 text.starts_with("/help"),
2524 "editor text should start with /help, got {text}"
2525 );
2526 }
2527}