1use std::cell::RefCell;
2use std::collections::HashMap;
3use std::io::Write;
4use std::path::PathBuf;
5use std::rc::{Rc, Weak};
6use std::sync::Arc;
7use std::time::Duration;
8
9use crate::agent::extension::ToolRenderer;
10use yoagent::types::AgentTool;
11
12use crate::agent::AgentSession;
13use crate::agent::extension::{CommandResult, Extension};
14use crate::agent::footer_data_provider::FooterDataProvider;
15use crate::agent::session::SessionEntry;
16use crate::auth;
17use crate::builtin::export;
18use crate::provider;
19use crate::provider::ProviderRegistry;
20
21use crate::agent::ui::chat_editor::{ChatEditor, InputAction};
22
23use crate::agent::ui::components::EditorComponent;
24use crate::agent::ui::components::FooterComponent;
25use crate::agent::ui::components::InfoMessageComponent;
26use crate::agent::ui::footer::Footer;
27use crate::agent::ui::theme::RabTheme;
28use crate::agent::ui::working::WorkingIndicator;
29use crate::builtin::commands::SessionInfoInternal;
30use crate::tui::Component;
31use crate::tui::TUI;
32use crate::tui::focusable::Focusable;
33
34pub type PendingLabelChanges = Rc<RefCell<Vec<(String, Option<String>)>>>;
36
37#[derive(Debug, Clone)]
39pub enum OverlayResult {
40 ModelSelected(String),
42 ScopedModelsAccepted(Option<Vec<String>>),
44 ScopedModelsCancelled,
46 LoginProviderSelected(String),
48 LoginApiKeyProvided { provider: String, key: String },
50 LoginAuthTypeSelected(AuthType),
52 LogoutProviderSelected(String),
54 ImportConfirmed(String),
56 ImportCancelled,
58 TreeNavigateTo(String),
60 TreeCancelled,
62 TreeSummarizeChoice {
65 entry_id: String,
66 summarize: bool,
67 custom_instructions: Option<String>,
68 },
69 TreeReopen(String),
71}
72
73use crate::agent::ui::components::oauth_selector::AuthType;
74use crate::agent::ui::theme::ThemeKey;
75use crate::tui::components::Spacer;
76use crate::tui::components::Text;
77use crate::tui::terminal::{self, ProcessTerminal, TerminalTrait};
78use crossterm::event::KeyEvent;
79use tokio::sync::mpsc;
80
81const ALL_THINKING_LEVELS: &[&str] = &["xhigh", "high", "medium", "low", "off"];
85
86fn available_thinking_levels(app: &App) -> Vec<&'static str> {
89 let thinking_map: Option<std::collections::HashMap<String, Option<serde_json::Value>>> = app
91 .registry
92 .resolve(&app.model, Some(&app.current_provider))
93 .ok()
94 .and_then(|r| {
95 r.model_config
96 .headers
97 .get("_rab_thinking_map")
98 .and_then(|json| serde_json::from_str(json).ok())
99 });
100
101 match thinking_map {
102 Some(map) => ALL_THINKING_LEVELS
103 .iter()
104 .filter(|level| {
105 if **level == "off" {
106 return true; }
108 !matches!(map.get(**level), Some(None))
110 })
111 .copied()
112 .collect(),
113 None => ALL_THINKING_LEVELS.to_vec(),
114 }
115}
116
117pub struct AppConfig {
119 pub model: String,
120 pub provider: String,
121 pub system_prompt: String,
122 pub extensions: Vec<Box<dyn Extension>>,
123 pub cwd: PathBuf,
124 pub thinking_level: Option<String>,
125 pub available_models: Vec<String>,
126 pub hide_thinking: bool,
127 pub collapse_tool_output: bool,
128 pub interactive: bool,
129 pub settings: crate::agent::settings::Settings,
130 pub context_files: Vec<String>,
132
133 pub skills: Vec<yoagent::skills::Skill>,
135 pub skill_dirs: Vec<PathBuf>,
137 pub agent_dir: PathBuf,
139 pub prompt_templates: Vec<crate::agent::prompt_templates::PromptTemplate>,
141 pub prompt_template_dirs: Vec<PathBuf>,
143 pub session_info: Option<std::sync::Arc<std::sync::Mutex<Option<SessionInfoInternal>>>>,
145 pub api_key: String,
147 pub registry: Arc<ProviderRegistry>,
149}
150
151pub struct App {
153 cwd: PathBuf,
154 model: String,
155 current_provider: String,
156 thinking_level: Option<String>,
157 system_prompt: String,
158 theme: RabTheme,
159
160 commands: Vec<(String, String)>,
162
163 available_models: Vec<String>,
165 registry: Arc<ProviderRegistry>,
167
168 pub chat_container: std::rc::Rc<std::cell::RefCell<crate::tui::Container>>,
171
172 pub status_section: std::rc::Rc<std::cell::RefCell<crate::tui::components::DynamicLines>>,
175 pub working_section: std::rc::Rc<std::cell::RefCell<crate::tui::components::DynamicLines>>,
177
178 editor: Rc<RefCell<ChatEditor>>,
180
181 event_tx: mpsc::UnboundedSender<yoagent::types::AgentEvent>,
183 event_rx: mpsc::UnboundedReceiver<yoagent::types::AgentEvent>,
184
185 is_streaming: bool,
187 pending_submit: Option<String>,
189 pending_compact: Option<Option<String>>,
191 pending_auto_compact: bool,
193 agent: Option<yoagent::agent::Agent>,
195 forward_handle: Option<tokio::task::JoinHandle<()>>,
198
199 oauth_join_handle: Option<tokio::task::JoinHandle<()>>,
201
202 pending_oauth_provider: Option<String>,
205
206 hide_thinking: bool,
208 collapse_tool_output: bool,
209 tools_expanded: bool,
211
212 scroll_offset: usize,
214
215 last_clear_time: std::time::Instant,
217
218 should_quit: bool,
220
221 pending_tool_executions: usize,
226
227 bash_abort_handle: Option<tokio::task::AbortHandle>,
229
230 session: Option<AgentSession>,
232
233 footer: Rc<RefCell<Footer>>,
235
236 footer_provider: Rc<RefCell<FooterDataProvider>>,
238
239 pending_tools: HashMap<String, Weak<RefCell<crate::agent::ui::components::ToolExecComponent>>>,
242
243 tool_call_start_times: HashMap<String, std::time::Instant>,
246
247 invalidate_rxs: Vec<tokio::sync::mpsc::UnboundedReceiver<()>>,
250
251 streaming_component:
254 Option<Weak<RefCell<crate::agent::ui::components::AssistantMessageComponent>>>,
255
256 working: WorkingIndicator,
258
259 status_text: Option<String>,
261
262 pending_command_result: Option<CommandResult>,
265
266 overlay_result_signal: Rc<RefCell<Option<OverlayResult>>>,
268
269 pending_scoped_ids: Rc<RefCell<Option<Vec<String>>>>,
271
272 extensions: Arc<Vec<Box<dyn Extension>>>,
275 skills: Vec<yoagent::skills::Skill>,
277 skill_dirs: Vec<PathBuf>,
279 agent_dir: PathBuf,
281 context_files: Vec<String>,
283 prompt_template_dirs: Vec<PathBuf>,
285 prompt_templates: Vec<crate::agent::prompt_templates::PromptTemplate>,
287 api_key: String,
289 session_info: Option<std::sync::Arc<std::sync::Mutex<Option<SessionInfoInternal>>>>,
291
292 auto_compact: bool,
294
295 settings: crate::agent::settings::Settings,
297
298 header: Rc<RefCell<crate::agent::ui::components::HeaderComponent>>,
303
304 scoped_model_ids: Option<Vec<String>>,
306
307 session_picker: Option<crate::agent::ui::components::SessionPicker>,
309
310 last_status_len: Option<usize>,
315 pending_label_changes: PendingLabelChanges,
317 }
320
321impl App {
322 fn new(config: AppConfig, session: AgentSession) -> Self {
323 let mut agent_session = session;
324 let model_config = config
325 .registry
326 .resolve(&config.model, Some(&config.provider))
327 .ok()
328 .map(|r| r.model_config.clone())
329 .unwrap_or_else(|| {
330 let mut mc = crate::agent::base_model_config(&config.model);
331 mc.context_window =
332 crate::agent::compaction::get_model_context_window(&config.model) as u32;
333 mc
334 });
335 agent_session.set_compaction_config(
336 config.api_key.clone(),
337 &config.model,
338 crate::agent::compaction::get_model_context_window(&config.model),
339 Some(model_config),
340 );
341 agent_session.set_registry(config.registry.clone());
342 agent_session.set_auto_compact(config.settings.auto_compact.unwrap_or(true));
343 let (tx, rx) = mpsc::unbounded_channel();
344 use crate::agent::ui::theme::current_theme;
345 let theme = current_theme().clone();
346
347 let mut editor = ChatEditor::new(&theme, config.cwd.clone());
348
349 use crate::tui::autocomplete::AutocompleteItem as AutoAutocompleteItem;
351 use crate::tui::autocomplete::SlashCommand as AutoSlashCommand;
352 let mut auto_commands: Vec<AutoSlashCommand> = config
353 .extensions
354 .iter()
355 .flat_map(|e| e.commands())
356 .map(|cmd| {
357 let handler = cmd.handler;
358 AutoSlashCommand {
359 name: cmd.name,
360 description: Some(cmd.description),
361 argument_hint: None,
362 argument_completions: None,
363 get_argument_completions: Some(std::sync::Arc::new(
364 move |prefix: &str| -> Vec<AutoAutocompleteItem> {
365 handler
366 .argument_completions(prefix)
367 .into_iter()
368 .map(|item| AutoAutocompleteItem {
369 value: item.value,
370 label: item.label,
371 description: item.description,
372 })
373 .collect()
374 },
375 )),
376 }
377 })
378 .collect();
379
380 for skill in &config.skills {
382 let cmd_name = format!("skill:{}", skill.name);
383 auto_commands.push(AutoSlashCommand {
384 name: cmd_name,
385 description: Some(skill.description.clone()),
386 argument_hint: None,
387 argument_completions: None,
388 get_argument_completions: None,
389 });
390 }
391
392 for template in &config.prompt_templates {
394 auto_commands.push(AutoSlashCommand {
395 name: template.name.clone(),
396 description: Some(template.description.clone()),
397 argument_hint: template.argument_hint.clone(),
398 argument_completions: None,
399 get_argument_completions: None,
400 });
401 }
402 editor.set_slash_commands(auto_commands);
403
404 let mut commands: Vec<(String, String)> = config
406 .extensions
407 .iter()
408 .flat_map(|e| e.commands())
409 .map(|c| (c.name, c.description))
410 .collect();
411
412 for skill in &config.skills {
414 commands.push((format!("skill:{}", skill.name), skill.description.clone()));
415 }
416
417 for template in &config.prompt_templates {
419 commands.push((template.name.clone(), template.description.clone()));
420 }
421
422 let editor = Rc::new(RefCell::new(editor));
423
424 let footer_provider = Rc::new(RefCell::new(FooterDataProvider::new(config.cwd.clone())));
425
426 let mut footer = Footer::new(
427 config.cwd.to_string_lossy().to_string(),
428 footer_provider.clone(),
429 );
430 footer.set_context_window(crate::agent::compaction::get_model_context_window(
431 &config.model,
432 ));
433
434 footer_provider
436 .borrow_mut()
437 .set_available_provider_count(config.registry.count_providers());
438
439 {
442 let has_model_entry = !agent_session
443 .session()
444 .find_entries("model_change")
445 .is_empty();
446 if !has_model_entry {
447 agent_session.on_model_change(&config.provider, &config.model);
448 }
449 let has_thinking_entry = !agent_session
450 .session()
451 .find_entries("thinking_level_change")
452 .is_empty();
453 if !has_thinking_entry && let Some(ref level) = config.thinking_level {
454 agent_session.on_thinking_level_change(level);
455 }
456 }
457
458 let footer = Rc::new(RefCell::new(footer));
459
460 let context = agent_session.session().build_session_context();
462 let history_messages = context.messages.clone();
463
464 let cwd_string = config.cwd.to_string_lossy().to_string();
468
469 let context_file_paths: Vec<String> = config
471 .context_files
472 .iter()
473 .map(|s| {
474 if let Some(rel) = s.strip_prefix(&cwd_string) {
476 if rel.is_empty() {
477 s.clone()
478 } else {
479 format!("./{}", rel.trim_start_matches('/'))
480 }
481 } else if let Some(home) =
482 std::env::var_os("HOME").and_then(|h| h.into_string().ok())
483 && let Some(rel) = s.strip_prefix(&home)
484 {
485 if rel.is_empty() {
486 s.clone()
487 } else {
488 format!("~/{}", rel.trim_start_matches('/'))
489 }
490 } else {
491 s.clone()
492 }
493 })
494 .collect();
495 let skill_names: Vec<String> = config.skills.iter().map(|s| s.name.clone()).collect();
496 let template_names: Vec<String> = config
497 .prompt_templates
498 .iter()
499 .map(|t| t.name.clone())
500 .collect();
501 let extension_names: Vec<String> = config
502 .extensions
503 .iter()
504 .map(|e| e.name().to_string())
505 .collect();
506 let theme_names: Vec<String> = crate::agent::ui::theme::get_available_themes()
508 .into_iter()
509 .filter(|n| n != "dark" && n != "light")
510 .collect();
511
512 let chat_container =
513 std::rc::Rc::new(std::cell::RefCell::new(crate::tui::Container::new()));
514 {
515 let mut chat = chat_container.borrow_mut();
516 rebuild_chat_from_messages(
517 &mut chat,
518 &history_messages,
519 &cwd_string,
520 config.hide_thinking,
521 config.collapse_tool_output,
522 &config.extensions,
523 );
524 }
525
526 let verbose = config.settings.verbose;
527
528 let mut result = Self {
529 cwd: config.cwd,
530 model: config.model,
531 current_provider: config.provider,
532 thinking_level: config.thinking_level,
533 system_prompt: config.system_prompt,
534 theme,
535 commands,
536 available_models: config.available_models,
537 registry: config.registry.clone(),
538 chat_container,
539 pending_tools: HashMap::new(),
540 tool_call_start_times: HashMap::new(),
541 invalidate_rxs: Vec::new(),
542 streaming_component: None,
543
544 status_section: std::rc::Rc::new(std::cell::RefCell::new(
545 crate::tui::components::DynamicLines::new(),
546 )),
547 working_section: std::rc::Rc::new(std::cell::RefCell::new(
548 crate::tui::components::DynamicLines::new(),
549 )),
550 editor,
551 event_tx: tx,
552 event_rx: rx,
553 is_streaming: false,
554 pending_submit: None,
555 pending_compact: None,
556 pending_auto_compact: false,
557 agent: None,
558 forward_handle: None,
559 oauth_join_handle: None,
560 pending_oauth_provider: None,
561 pending_command_result: None,
562 overlay_result_signal: Rc::new(RefCell::new(None)),
563 pending_scoped_ids: Rc::new(RefCell::new(None)),
564 hide_thinking: config.hide_thinking,
565 collapse_tool_output: config.collapse_tool_output,
566 tools_expanded: !config.collapse_tool_output,
567 scroll_offset: 0,
568 last_clear_time: std::time::Instant::now(),
569
570 should_quit: false,
571 pending_tool_executions: 0,
572 bash_abort_handle: None,
573 session: Some(agent_session),
574 footer,
575 footer_provider,
576 working: WorkingIndicator::new(),
577 extensions: Arc::new(config.extensions),
578
579 skills: config.skills,
580 skill_dirs: config.skill_dirs,
581 agent_dir: config.agent_dir,
582 prompt_template_dirs: config.prompt_template_dirs,
583 prompt_templates: config.prompt_templates,
584 session_info: config.session_info,
585 api_key: config.api_key,
586 scoped_model_ids: config.settings.enabled_models.clone(),
587 settings: config.settings,
588 auto_compact: true,
589 status_text: None,
590 context_files: context_file_paths.clone(),
591 header: Rc::new(RefCell::new(
592 crate::agent::ui::components::HeaderComponent::new_with_expanded(
593 !config.collapse_tool_output || verbose,
594 ),
595 )),
596 session_picker: None,
597 last_status_len: None,
598 pending_label_changes: Rc::new(RefCell::new(Vec::new())),
599 };
600
601 {
603 let mut hdr = result.header.borrow_mut();
604 hdr.set_resource_data(
605 context_file_paths,
606 skill_names,
607 template_names,
608 extension_names,
609 theme_names,
610 );
611 }
612
613 result.update_session_info();
615
616 if let Some(ref mut s) = result.session {
618 result.footer.borrow_mut().refresh_from_session(s.session());
619 }
620
621 result
622 }
623
624 fn update_session_info(&self) {
626 if let Some(ref session) = self.session
627 && let Some(ref info) = self.session_info
628 {
629 let si = crate::builtin::commands::compute_session_info(session.session());
630 if let Ok(mut guard) = info.lock() {
631 *guard = Some(si);
632 }
633 }
634 }
635
636 fn refresh_git_branch(&self) {
639 self.footer_provider.borrow_mut().refresh_git_branch();
640 }
641
642 fn clear_session_state(&mut self) {
644 self.chat_container.borrow_mut().clear();
645 self.streaming_component = None;
646 self.pending_tools.clear();
647 self.tool_call_start_times.clear();
648 self.pending_submit = None;
649 }
650
651 fn rebuild_from_session_context(&mut self) {
654 if let Some(ref agent_session) = self.session {
655 let context = agent_session.session().build_session_context();
656 {
657 let mut chat = self.chat_container.borrow_mut();
658 rebuild_chat_from_messages(
659 &mut chat,
660 &context.messages,
661 &self.cwd.to_string_lossy(),
662 self.hide_thinking,
663 self.collapse_tool_output,
664 &self.extensions,
665 );
666 }
667 if let Some(ref mut agent) = self.agent {
668 agent.replace_messages(context.messages);
669 }
670 }
671 }
672
673 fn record_model_change(&mut self, model: &str) {
675 if let Some(ref mut agent_session) = self.session {
676 agent_session.on_model_change(&self.current_provider, model);
677 }
678 if let Some(ref session) = self.session {
679 self.footer
680 .borrow_mut()
681 .refresh_from_session(session.session());
682 }
683 }
684
685 fn refresh_registry(&mut self) {
688 match provider::ProviderRegistry::load(&provider::get_agent_dir()) {
689 Ok(new_reg) => self.registry = Arc::new(new_reg),
690 Err(e) => {
691 self.status_text = Some(format!("Failed to refresh registry: {}", e));
692 }
693 }
694 }
695
696 fn propagate_hide_thinking(&mut self) {
698 let hide = self.hide_thinking;
699 {
700 let mut chat = self.chat_container.borrow_mut();
701 for child in chat.children_mut().iter_mut() {
702 child.set_hide_thinking(hide);
703 }
704 }
705 if let Some(weak) = self.streaming_component.as_ref().and_then(|w| w.upgrade()) {
706 weak.borrow_mut().set_hide_thinking(hide);
707 }
708 }
709
710 fn switch_to_session(&mut self, new_session: AgentSession) {
712 let ctx = new_session.session().build_session_context();
713 self.clear_session_state();
714 rebuild_chat_from_messages(
715 &mut self.chat_container.borrow_mut(),
716 &ctx.messages,
717 &self.cwd.to_string_lossy(),
718 self.hide_thinking,
719 self.collapse_tool_output,
720 &self.extensions,
721 );
722 self.footer
724 .borrow_mut()
725 .refresh_from_session(new_session.session());
726
727 self.session = Some(new_session);
728 self.agent = None;
729 self.update_session_info();
730 }
731}
732
733pub async fn run(config: AppConfig, session: AgentSession) -> anyhow::Result<()> {
735 crate::agent::ui::theme::init_theme(Some("dark"), false);
737
738 let mut term = ProcessTerminal::new();
739 let mut stdout = std::io::stdout();
740
741 term.start(&mut stdout)?;
745 term.hide_cursor(&mut stdout)?;
746 term.set_color_scheme_notifications(&mut stdout, true)?;
747 crate::tui::terminal::start_stdin_reader();
748
749 let mut tui = TUI::new();
750 tui.set_clear_on_shrink(false);
753 let mut app = App::new(config, session);
754
755 app.editor.borrow_mut().editor.set_focused(true);
757
758 tui.root.add_child(std::boxed::Box::new(Spacer::new(1)));
761 tui.root.add_child(std::boxed::Box::new(
762 crate::tui::components::RcRefCellComponent(
763 app.header.clone() as Rc<RefCell<dyn Component>>,
764 ),
765 ));
766 tui.root.add_child(std::boxed::Box::new(Spacer::new(1)));
767 tui.root.add_child(std::boxed::Box::new(
768 crate::tui::components::RcRefCellComponent(app.chat_container.clone()
769 as std::rc::Rc<std::cell::RefCell<dyn crate::tui::Component>>),
770 ));
771 tui.root.add_child(std::boxed::Box::new(
772 crate::tui::components::RcRefCellComponent(app.status_section.clone()
773 as std::rc::Rc<std::cell::RefCell<dyn crate::tui::Component>>),
774 ));
775 tui.root.add_child(std::boxed::Box::new(
776 crate::tui::components::RcRefCellComponent(app.working_section.clone()
777 as std::rc::Rc<std::cell::RefCell<dyn crate::tui::Component>>),
778 ));
779 tui.root
780 .add_child(std::boxed::Box::new(EditorComponent(app.editor.clone())));
781 tui.root
782 .add_child(std::boxed::Box::new(FooterComponent(app.footer.clone())));
783
784 app.editor.borrow_mut().update_border_color(
786 app.thinking_level.as_deref(),
787 &app.theme as &dyn crate::tui::Theme,
788 );
789
790 let mut cols: u16 = 80;
793 let mut rows: u16 = 24;
794 let mut dirty = true; loop {
797 let mut had_event = false;
802 while let Ok(event) = app.event_rx.try_recv() {
803 handle_agent_event(&mut app, event);
804 had_event = true;
805 }
806 if had_event {
807 dirty = true;
808 }
809
810 loop {
814 match terminal::try_recv_terminal_event() {
815 Some(terminal::TerminalEvent::Key(key)) => {
816 if !tui.route_input(&key) {
818 handle_input(&mut app, &mut tui, &mut term, &key);
819 }
820 }
821 Some(terminal::TerminalEvent::Paste(content)) => {
822 if !tui.route_paste(&content) {
825 app.editor.borrow_mut().editor.handle_paste(&content);
826 }
827 }
828 Some(terminal::TerminalEvent::Resize(w, h)) => {
829 app.editor.borrow_mut().editor.set_terminal_rows(h as usize);
830 tui.set_dimensions(w as usize, h as usize);
831 }
832 None => break,
833 }
834 dirty = true;
835 }
836
837 if let Some(ids) = app.pending_scoped_ids.borrow_mut().take() {
841 let auth_count = app.registry.list_authenticated_model_ids().len();
842 if ids.is_empty() || ids.len() >= auth_count {
843 app.scoped_model_ids = None;
844 } else {
845 app.scoped_model_ids = Some(ids);
846 }
847 dirty = true;
848 }
849
850 if tui.has_overlays() {
852 let changes = app
853 .pending_label_changes
854 .borrow_mut()
855 .drain(..)
856 .collect::<Vec<_>>();
857 for (entry_id, label) in changes {
858 if let Some(ref mut session) = app.session {
859 let _ = session
860 .session_mut()
861 .append_label_change(&entry_id, label.as_deref());
862 }
863 }
864 }
865
866 if tui.has_overlays() {
868 let result = app.overlay_result_signal.borrow_mut().take();
869 if let Some(result) = result {
870 tui.pop_overlay();
871 match result {
872 OverlayResult::ModelSelected(full_id) => {
873 if !full_id.is_empty() {
874 let (provider, model_id) = full_id
875 .split_once('/')
876 .map(|(p, m)| (p.to_string(), m.to_string()))
877 .unwrap_or_else(|| (String::new(), full_id.clone()));
878 app.current_provider = provider;
879 app.model = model_id.clone();
880 app.record_model_change(&model_id);
881 app.status_text = Some(format!("Model: {}", full_id));
882 }
883 }
884 OverlayResult::ScopedModelsAccepted(ids) => {
885 match ids {
886 Some(ids)
887 if !ids.is_empty()
888 && ids.len()
889 < app.registry.list_authenticated_model_ids().len() =>
890 {
891 app.scoped_model_ids = Some(ids.clone());
892 app.settings.set_enabled_models(Some(ids));
894 if let Err(e) = app.settings.save() {
895 app.status_text =
896 Some(format!("Failed to save model scope: {}", e));
897 } else {
898 app.status_text = Some("Model scope saved to settings".into());
899 }
900 }
901 _ => {
902 app.scoped_model_ids = None;
904 app.settings.set_enabled_models(None);
905 if let Err(e) = app.settings.save() {
906 app.status_text =
907 Some(format!("Failed to save model scope: {}", e));
908 } else if ids.is_some() {
909 app.status_text = Some("Model scope saved to settings".into());
910 }
911 }
912 }
913 }
914 OverlayResult::ScopedModelsCancelled => {
915 }
917 OverlayResult::LoginAuthTypeSelected(auth_type) => {
918 show_login_provider_selector(&mut app, &mut tui, Some(auth_type));
920 }
921 OverlayResult::LoginProviderSelected(provider_id) => {
922 if crate::provider::oauth::get(&provider_id).is_some() {
924 show_oauth_login_dialog(&mut app, &mut tui, &provider_id);
926 } else {
927 show_api_key_login_dialog(&mut app, &mut tui, &provider_id);
929 }
930 }
931 OverlayResult::LoginApiKeyProvided { provider, key } => {
932 if let Some(err_msg) = key.strip_prefix("OAUTH_LOGIN_FAILED:") {
934 app.status_text = Some(format!("OAuth login failed: {}", err_msg));
935 } else {
936 match auth::login(&provider, &key) {
937 Ok(_) => {
938 app.status_text = Some(format!("Logged in to {}", provider));
939 app.refresh_registry();
940 complete_login(&mut app, &provider, AuthType::ApiKey);
941 }
942 Err(e) => {
943 app.status_text = Some(format!("Login failed: {}", e));
944 }
945 }
946 }
947 }
948 OverlayResult::LogoutProviderSelected(provider_id) => {
949 match auth::logout(Some(&provider_id)) {
950 Ok(true) => {
951 app.status_text = Some(format!("Logged out from {}", provider_id));
952 app.refresh_registry();
953 }
954 Ok(false) => {
955 app.status_text =
956 Some(format!("No credentials for {}", provider_id));
957 }
958 Err(e) => {
959 app.status_text = Some(format!("Logout failed: {}", e));
960 }
961 }
962 }
963 OverlayResult::ImportConfirmed(path) => {
964 let result = (|| -> Result<PathBuf, String> {
965 let resolved = crate::builtin::resolve_path(&path, &app.cwd);
966 if !resolved.exists() {
967 return Err(format!("File not found: {}", resolved.display()));
968 }
969
970 let session_dir = app
972 .session
973 .as_ref()
974 .map(|s| s.session_manager().session_dir().to_path_buf())
975 .unwrap_or_else(|| {
976 crate::agent::session::get_default_session_dir(&app.cwd)
977 });
978
979 std::fs::create_dir_all(&session_dir)
981 .map_err(|e| format!("Failed to create session dir: {}", e))?;
982
983 let dest = session_dir.join(
985 resolved
986 .file_name()
987 .unwrap_or_else(|| std::ffi::OsStr::new("session.jsonl")),
988 );
989 if dest != resolved {
990 std::fs::copy(&resolved, &dest)
991 .map_err(|e| format!("Failed to copy session file: {}", e))?;
992 }
993
994 let agent_session = crate::agent::AgentSession::open(
995 &dest,
996 Some(&session_dir),
997 Some(&app.cwd),
998 );
999 app.working.stop();
1000 app.status_text = None;
1001 app.switch_to_session(agent_session);
1002 Ok(dest)
1003 })();
1004
1005 match result {
1006 Ok(path) => {
1007 chat_info(
1008 &mut app,
1009 format!(
1010 "✓ Imported and switched to session: {}",
1011 crate::builtin::shorten_path(&path.to_string_lossy())
1012 ),
1013 );
1014 }
1015 Err(msg) => {
1016 chat_info(&mut app, format!("✗ {}", msg));
1017 }
1018 }
1019 }
1020 OverlayResult::ImportCancelled => {
1021 chat_info(&mut app, "Import cancelled.");
1022 }
1023 OverlayResult::TreeNavigateTo(entry_id) => {
1024 let current_leaf =
1026 app.session.as_ref().and_then(|s| s.session().get_leaf_id());
1027 if current_leaf.as_deref() == Some(&entry_id) {
1028 app.status_text = Some("Already at this point".to_string());
1029 } else {
1030 show_summarization_prompt(&mut app, &mut tui, &entry_id);
1032 }
1033 }
1034 OverlayResult::TreeCancelled => {
1035 }
1037 OverlayResult::TreeSummarizeChoice {
1038 entry_id,
1039 summarize,
1040 custom_instructions,
1041 } => {
1042 if summarize {
1044 if let Some(ref mut session) = app.session {
1045 match session
1046 .set_branch(&entry_id, custom_instructions.as_deref())
1047 .await
1048 {
1049 Ok(_) => {
1050 app.status_text =
1051 Some("Navigated to selected point".to_string());
1052 app.rebuild_from_session_context();
1053 }
1054 Err(e) => {
1055 app.status_text = Some(format!("Navigation error: {}", e));
1056 }
1057 }
1058 }
1059 } else {
1060 if let Some(ref mut session) = app.session {
1062 match session.session_mut().set_leaf_id(Some(&entry_id)) {
1063 Ok(_) => {
1064 app.status_text = Some(
1065 "Navigated to selected point (no summary)".to_string(),
1066 );
1067 app.rebuild_from_session_context();
1068 }
1069 Err(e) => {
1070 app.status_text = Some(format!("Navigation error: {}", e));
1071 }
1072 }
1073 }
1074 }
1075 }
1076 OverlayResult::TreeReopen(entry_id) => {
1077 if let Some(ref session) = app.session {
1079 let tree = session.session_manager().get_tree();
1080 let leaf_id = session.session().get_leaf_id();
1081 let signal_select = app.overlay_result_signal.clone();
1082 let signal_cancel = app.overlay_result_signal.clone();
1083 let label_signal = app.pending_label_changes.clone();
1084 let mut tree_selector = crate::agent::ui::components::TreeSelector::new(
1085 tree,
1086 leaf_id,
1087 rows as usize,
1088 None,
1089 );
1090 if !entry_id.is_empty() {
1092 tree_selector.set_initial_selection(&entry_id);
1093 }
1094 tree_selector.on_select = Some(Box::new(move |eid| {
1095 *signal_select.borrow_mut() =
1096 Some(OverlayResult::TreeNavigateTo(eid));
1097 }));
1098 tree_selector.on_cancel = Some(Box::new(move || {
1099 *signal_cancel.borrow_mut() = Some(OverlayResult::TreeCancelled);
1100 }));
1101 tree_selector.on_label_change = Some(Box::new(move |eid, label| {
1102 label_signal.borrow_mut().push((eid, label));
1103 }));
1104 tui.show_top_overlay(Box::new(tree_selector));
1105 }
1106 }
1107 }
1108 }
1109 dirty = true;
1110 }
1111
1112 while let Ok(event) = app.event_rx.try_recv() {
1119 handle_agent_event(&mut app, event);
1120 dirty = true;
1121 }
1122
1123 if app.forward_handle.as_ref().is_some_and(|h| h.is_finished()) {
1129 app.forward_handle.take();
1130 if let Some(ref mut agent) = app.agent {
1131 agent.finish().await;
1133 }
1134 }
1135
1136 if app
1138 .oauth_join_handle
1139 .as_ref()
1140 .is_some_and(|h| h.is_finished())
1141 {
1142 app.oauth_join_handle.take();
1143
1144 let oauth_provider = app.pending_oauth_provider.take();
1149 if let Some(ref provider_id) = oauth_provider
1150 && let Ok(Some(auth::AuthCredential::Oauth { .. })) =
1151 auth::read_credential(provider_id)
1152 {
1153 let provider_name = app
1154 .registry
1155 .list_providers()
1156 .into_iter()
1157 .find(|(id, _)| id == provider_id)
1158 .map(|(_, name)| name)
1159 .unwrap_or_else(|| provider_id.clone());
1160 let msg = format!("✓ Logged in to {} via OAuth", provider_name);
1161 app.status_text = Some(msg.clone());
1162 chat_info(&mut app, &msg);
1163 app.refresh_registry();
1164 complete_login(
1165 &mut app,
1166 provider_id,
1167 crate::agent::ui::components::oauth_selector::AuthType::OAuth,
1168 );
1169 } else if oauth_provider.is_some() {
1170 let err_msg = app.status_text.clone().unwrap_or_default();
1173 if !err_msg.is_empty() {
1174 chat_info(&mut app, &err_msg);
1175 }
1176 }
1177 }
1178
1179 if !app.is_streaming
1184 && let Some(text) = app.pending_submit.take()
1185 {
1186 start_agent_loop(&mut app, text).await;
1187 dirty = true;
1188 }
1189
1190 if let Some(custom_instructions) = app.pending_compact.take() {
1192 handle_compact_command(&mut app, custom_instructions).await;
1193 dirty = true;
1194 }
1195
1196 if app.pending_auto_compact {
1200 app.pending_auto_compact = false;
1201 handle_auto_compact(&mut app).await;
1202 dirty = true;
1203 }
1204
1205 if let Some(result) = app.pending_command_result.take() {
1207 match result {
1208 CommandResult::ShowHelp => {
1209 show_help_overlay(&mut app, &mut tui);
1210 }
1211 CommandResult::OpenSessionSelector => {
1212 let mut picker = crate::agent::ui::components::SessionPicker::new();
1214 let repo = crate::agent::DefaultSessionRepo::new();
1215 picker.load_sessions(&repo);
1216 app.session_picker = Some(picker);
1217 app.status_text = None;
1218 }
1219 CommandResult::OpenModelSelector => {
1220 open_model_selector(&mut app, &mut tui);
1221 }
1222 CommandResult::OpenSettings => {
1223 chat_info(&mut app, "Settings menu - not yet implemented.");
1224 }
1225 CommandResult::ScopedModels => {
1226 open_scoped_models_selector(&mut app, &mut tui);
1227 }
1228 CommandResult::Login {
1229 ref provider,
1230 ref api_key,
1231 } => {
1232 if let (Some(provider), Some(key)) = (provider, api_key) {
1233 handle_login(&mut app, provider, Some(key));
1234 } else if let Some(provider) = provider {
1235 show_api_key_login_dialog(&mut app, &mut tui, provider);
1237 } else {
1238 show_auth_type_or_provider_selector(&mut app, &mut tui);
1240 }
1241 }
1242 CommandResult::Logout { provider } => match provider {
1243 Some(p) => handle_logout(&mut app, Some(&p)),
1244 None => show_logout_provider_selector(&mut app, &mut tui),
1245 },
1246 CommandResult::ImportSession { path } => {
1247 let resolved = crate::builtin::resolve_path(&path, &app.cwd);
1248 if !resolved.exists() {
1249 chat_info(
1250 &mut app,
1251 format!("✗ File not found: {}", resolved.display()),
1252 );
1253 } else {
1254 let display_path = resolved.display().to_string();
1255 let signal = app.overlay_result_signal.clone();
1256 let path_for_confirm = path.clone();
1257 let mut confirm =
1258 Box::new(crate::agent::ui::components::ConfirmOverlay::new(
1259 "Import Session",
1260 format!("Replace current session with {}?", display_path),
1261 ));
1262 confirm.on_confirm({
1263 let signal = signal.clone();
1264 move || {
1265 *signal.borrow_mut() =
1266 Some(OverlayResult::ImportConfirmed(path_for_confirm));
1267 }
1268 });
1269 confirm.on_cancel({
1270 let signal = signal.clone();
1271 move || {
1272 *signal.borrow_mut() = Some(OverlayResult::ImportCancelled);
1273 }
1274 });
1275 tui.show_overlay(confirm, Default::default());
1276 }
1277 }
1278 CommandResult::SessionTree => {
1279 if let Some(ref session) = app.session {
1281 let tree = session.session_manager().get_tree();
1282 let leaf_id = session.session().get_leaf_id();
1283 let signal_select = app.overlay_result_signal.clone();
1284 let signal_cancel = app.overlay_result_signal.clone();
1285 let label_signal = app.pending_label_changes.clone();
1286 let mut tree_selector = crate::agent::ui::components::TreeSelector::new(
1287 tree,
1288 leaf_id,
1289 rows as usize,
1290 None,
1291 );
1292 tree_selector.on_select = Some(Box::new(move |entry_id| {
1293 *signal_select.borrow_mut() =
1294 Some(OverlayResult::TreeNavigateTo(entry_id));
1295 }));
1296 tree_selector.on_cancel = Some(Box::new(move || {
1297 *signal_cancel.borrow_mut() = Some(OverlayResult::TreeCancelled);
1298 }));
1299 tree_selector.on_label_change = Some(Box::new(move |entry_id, label| {
1300 label_signal.borrow_mut().push((entry_id, label));
1301 }));
1302 use crate::tui::focusable::Focusable;
1303 tree_selector.set_focused(true);
1304 tui.show_top_overlay(Box::new(tree_selector));
1305 } else {
1306 chat_info(&mut app, "No active session.");
1307 }
1308 }
1309 _ => {}
1310 }
1311 dirty = true;
1312 }
1313
1314 app.invalidate_rxs.retain_mut(|rx| {
1316 if rx.try_recv().is_ok() {
1317 dirty = true;
1318 true
1319 } else {
1320 !rx.is_closed()
1321 }
1322 });
1323
1324 if dirty && let Ok((w, h)) = term.size() {
1327 app.editor.borrow_mut().editor.set_terminal_rows(h as usize);
1328 cols = w;
1329 rows = h;
1330 }
1331
1332 if app.working.tick() {
1334 dirty = true;
1335 }
1336
1337 let mut tools_to_remove: Vec<String> = Vec::new();
1339 for (id, weak) in app.pending_tools.iter() {
1340 if let Some(comp) = weak.upgrade() {
1341 if comp.borrow_mut().tick_timer() {
1342 dirty = true;
1343 }
1344 } else {
1345 tools_to_remove.push(id.clone());
1346 }
1347 }
1348 for id in tools_to_remove {
1349 app.pending_tools.remove(&id);
1350 }
1351
1352 if dirty {
1354 compose_ui(&mut app, cols as usize);
1356 tui.set_dimensions(cols as usize, rows as usize);
1357 tui.render(cols as usize, rows as usize, &mut stdout)?;
1358 dirty = false;
1359 }
1360
1361 tokio::time::sleep(if dirty || app.is_streaming || app.working.should_show() {
1365 Duration::from_millis(16)
1366 } else {
1367 Duration::from_millis(50)
1368 })
1369 .await;
1370
1371 app.status_text = None;
1373
1374 if app.should_quit {
1375 if let Some(handle) = app.oauth_join_handle.take() {
1377 handle.abort();
1378 }
1379 break;
1380 }
1381 }
1382
1383 tui.finalize(&mut stdout)?;
1386 term.set_color_scheme_notifications(&mut stdout, false)?;
1387 term.show_cursor(&mut stdout)?;
1388 term.stop(&mut stdout)?;
1389
1390 Ok(())
1391}
1392
1393fn compose_ui(app: &mut App, width: usize) {
1399 if let Some(ref picker) = app.session_picker {
1401 let (_lines, _cursor_y) = picker.render(width, &app.theme as &dyn crate::tui::Theme);
1402 app.chat_container.borrow_mut().clear();
1404 app.status_section.borrow_mut().set_lines(vec![]);
1405 app.working_section.borrow_mut().set_lines(vec![]);
1406 return;
1407 }
1408
1409 let mut status_lines = Vec::new();
1411 if let Some(ref status) = app.status_text {
1412 let line = app.theme.fg_key(ThemeKey::Dim, &format!(" {}", status));
1413 status_lines.push(crate::agent::ui::render_utils::pad_to_width(&line, width));
1414 }
1415
1416 if app.is_streaming {
1418 if let Some(ref msg) = app.pending_submit {
1420 let preview = if msg.len() > 60 {
1421 format!("{}…", &msg[..60])
1422 } else {
1423 msg.clone()
1424 };
1425 let line = app
1426 .theme
1427 .fg_key(ThemeKey::Dim, &format!(" 📝 queued: {}", preview));
1428 status_lines.push(crate::agent::ui::render_utils::pad_to_width(&line, width));
1429 }
1430 }
1431 app.status_section.borrow_mut().set_lines(status_lines);
1432
1433 let mut working_lines = Vec::new();
1435 let wl = app.working.render(width);
1436 working_lines.extend(wl);
1437 app.working_section.borrow_mut().set_lines(working_lines);
1438}
1439
1440fn user_agent_message(text: &str) -> yoagent::types::AgentMessage {
1442 yoagent::types::AgentMessage::Llm(yoagent::types::Message::User {
1443 content: vec![yoagent::types::Content::Text {
1444 text: text.to_string(),
1445 }],
1446 timestamp: yoagent::types::now_ms(),
1447 })
1448}
1449
1450fn handle_input(app: &mut App, tui: &mut TUI, term: &mut ProcessTerminal, key: &KeyEvent) {
1459 if app.session_picker.is_some() {
1461 handle_session_picker_input(app, key);
1462 return;
1463 }
1464
1465 if tui.has_overlays() && matches!(key.code, crossterm::event::KeyCode::Esc) {
1470 tui.pop_overlay();
1471 return;
1472 }
1473 if tui.has_overlays() {
1474 return;
1476 }
1477
1478 if tui.root.handle_input(key) {
1483 return;
1484 }
1485
1486 let action = app.editor.borrow_mut().handle_input(key);
1489 match action {
1490 InputAction::Handled => {}
1491 InputAction::Escape => {
1492 if app.is_streaming {
1494 interrupt_streaming(app);
1495 } else {
1496 app.editor.borrow_mut().editor.set_text("");
1497 }
1498 }
1499 InputAction::Clear => {
1500 handle_clear(app);
1501 }
1502 InputAction::Exit => {
1503 app.should_quit = true;
1504 }
1505 InputAction::ThinkingCycle => {
1506 handle_thinking_cycle(app);
1507 }
1508 InputAction::ModelSelector => {
1509 open_model_selector(app, tui);
1510 }
1511 InputAction::ModelCycleForward => {
1512 handle_model_cycle(app, 1);
1513 }
1514 InputAction::ModelCycleBackward => {
1515 handle_model_cycle(app, -1);
1516 }
1517 InputAction::ToggleThinking => {
1518 app.hide_thinking = !app.hide_thinking;
1519 app.propagate_hide_thinking();
1521 app.settings.set_hide_thinking(Some(app.hide_thinking));
1523 if let Err(e) = app.settings.save() {
1524 app.status_text = Some(format!("Failed to save thinking visibility: {}", e));
1525 }
1526 show_status(
1527 app,
1528 if app.hide_thinking {
1529 "Thinking blocks: hidden".to_string()
1530 } else {
1531 "Thinking blocks: visible".to_string()
1532 },
1533 );
1534 }
1535 InputAction::ToolsExpand => {
1536 handle_tools_expand(app);
1537 }
1538 InputAction::EditorExternal => {
1539 handle_editor_external(app, tui, term);
1540 }
1541 InputAction::Help => {
1542 show_help_overlay(app, tui);
1543 }
1544 InputAction::Submit(text) => {
1545 submit_message(app, text);
1546 }
1547 InputAction::FollowUp(text) => {
1548 handle_follow_up(app, text);
1549 }
1550 InputAction::Dequeue => {
1551 if let Some(msg) = app.pending_submit.take() {
1553 app.editor.borrow_mut().editor.set_text(&msg);
1554 app.status_text = Some("Queued message restored to editor".into());
1555 } else {
1556 app.status_text = Some("No queued message".into());
1557 }
1558 }
1559 InputAction::CompactToggle => {
1560 handle_compact_toggle(app);
1561 }
1562 }
1563}
1564
1565fn handle_clear(app: &mut App) {
1571 let now = std::time::Instant::now();
1572 let elapsed = now.duration_since(app.last_clear_time);
1573 app.last_clear_time = now;
1574
1575 if app.is_streaming {
1576 interrupt_streaming(app);
1577 } else if elapsed.as_millis() < 500 {
1578 app.should_quit = true;
1580 } else {
1581 app.editor.borrow_mut().editor.set_text("");
1582 app.status_text = Some("Cleared".into());
1583 }
1584}
1585
1586fn handle_thinking_cycle(app: &mut App) {
1588 if app.available_models.is_empty() && app.model.is_empty() {
1589 app.status_text = Some("No model selected".into());
1590 return;
1591 }
1592
1593 let levels = available_thinking_levels(app);
1594 if levels.is_empty() {
1595 return;
1596 }
1597
1598 let current = app.thinking_level.as_deref().unwrap_or("off");
1599 let next = match levels.iter().position(|&l| l == current) {
1600 Some(pos) => levels[(pos + 1) % levels.len()],
1601 None => "off",
1602 };
1603
1604 app.thinking_level = Some(next.to_string());
1605 app.editor
1606 .borrow_mut()
1607 .update_border_color(Some(next), &app.theme as &dyn crate::tui::Theme);
1608 app.settings
1609 .set_default_thinking_level(Some(next.to_string()));
1610 if let Err(e) = app.settings.save() {
1611 app.status_text = Some(format!("Failed to save thinking level: {}", e));
1612 }
1613 if let Some(ref mut agent_session) = app.session {
1615 agent_session.on_thinking_level_change(next);
1616 }
1617 if let Some(ref s) = app.session {
1618 app.footer.borrow_mut().refresh_from_session(s.session());
1619 }
1620 show_status(app, format!("Thinking level: {}", next));
1621}
1622
1623fn handle_model_cycle(app: &mut App, dir: isize) {
1626 let authenticated_models = app.registry.list_authenticated_model_ids();
1629 let model_pool: Vec<String> = if let Some(ref scoped) = app.scoped_model_ids
1630 && !scoped.is_empty()
1631 {
1632 scoped
1635 .iter()
1636 .filter_map(|full_id| {
1637 let (_provider, model_id) = full_id.split_once('/')?;
1638 if authenticated_models.iter().any(|m| m == model_id) {
1639 Some(model_id.to_string())
1640 } else {
1641 None
1642 }
1643 })
1644 .collect()
1645 } else {
1646 authenticated_models
1647 };
1648
1649 let n = model_pool.len();
1650 if n == 0 {
1651 app.status_text = Some("No models available".into());
1652 return;
1653 }
1654
1655 let current_idx = model_pool.iter().position(|m| m == &app.model);
1656
1657 let next_idx = match current_idx {
1658 Some(idx) => (idx as isize + dir).rem_euclid(n as isize) as usize,
1659 None => 0,
1660 };
1661
1662 let model = model_pool[next_idx].clone();
1663 app.model = model.clone();
1664 app.current_provider = app
1665 .registry
1666 .provider_for_model(&model, Some(&app.current_provider))
1667 .unwrap_or_default();
1668 app.record_model_change(&model);
1669 show_status(app, format!("Model: {}", app.model));
1670}
1671
1672fn handle_tools_expand(app: &mut App) {
1676 app.tools_expanded = !app.tools_expanded;
1677 app.collapse_tool_output = !app.tools_expanded;
1678
1679 app.header.borrow_mut().set_expanded(app.tools_expanded);
1682
1683 let mut chat = app.chat_container.borrow_mut();
1685 for child in chat.children_mut().iter_mut() {
1686 child.set_expanded(app.tools_expanded);
1687 }
1688 drop(chat);
1689
1690 app.settings
1691 .set_collapse_tool_output(Some(app.collapse_tool_output));
1692 if let Err(e) = app.settings.save() {
1693 app.status_text = Some(format!("Failed to save tool output setting: {}", e));
1694 }
1695 show_status(
1696 app,
1697 if app.tools_expanded {
1698 "Tool output: expanded".to_string()
1699 } else {
1700 "Tool output: collapsed".to_string()
1701 },
1702 );
1703}
1704
1705fn handle_editor_external(app: &mut App, tui: &mut TUI, term: &mut ProcessTerminal) {
1708 let editor_cmd = std::env::var("VISUAL")
1709 .or_else(|_| std::env::var("EDITOR"))
1710 .unwrap_or_default();
1711
1712 if editor_cmd.is_empty() {
1713 app.status_text = Some("No editor configured. Set $VISUAL or $EDITOR.".into());
1714 return;
1715 }
1716
1717 let tmp_dir = std::env::temp_dir();
1718 let tmp_file = tmp_dir.join(format!(
1719 "rab-editor-{}.md",
1720 std::time::SystemTime::now()
1721 .duration_since(std::time::UNIX_EPOCH)
1722 .map(|d| d.as_nanos())
1723 .unwrap_or(0)
1724 ));
1725
1726 let current_text = app.editor.borrow().editor.get_text();
1727 if let Err(e) = std::fs::write(&tmp_file, ¤t_text) {
1728 app.status_text = Some(format!("Failed to write temp file: {}", e));
1729 return;
1730 }
1731
1732 let parts: Vec<&str> = editor_cmd.split(' ').collect();
1733 let (editor, args) = parts.split_first().unwrap_or((&"", &[]));
1734
1735 app.status_text = Some(format!("Opening {} ...", editor_cmd));
1737 let mut suspend_buf = Vec::new();
1738 let _ = term.stop(&mut suspend_buf);
1739 let _ = term.show_cursor(&mut suspend_buf);
1740 if !suspend_buf.is_empty() {
1741 let stdout = std::io::stdout();
1742 let mut handle = stdout.lock();
1743 let _ = handle.write_all(&suspend_buf);
1744 let _ = handle.flush();
1745 }
1746
1747 crate::tui::terminal::stop_stdin_reader();
1749 crate::tui::terminal::join_stdin_reader();
1750
1751 let status = std::process::Command::new(editor)
1753 .args(args)
1754 .arg(&tmp_file)
1755 .status();
1756
1757 let mut resume_buf = Vec::new();
1759 let _ = term.start(&mut resume_buf);
1760 let _ = term.hide_cursor(&mut resume_buf);
1761 if !resume_buf.is_empty() {
1762 let stdout = std::io::stdout();
1763 let mut handle = stdout.lock();
1764 let _ = handle.write_all(&resume_buf);
1765 let _ = handle.flush();
1766 }
1767 crate::tui::terminal::start_stdin_reader();
1769 tui.request_render();
1771
1772 match status {
1773 Ok(status) if status.success() => {
1774 if let Ok(new_content) = std::fs::read_to_string(&tmp_file) {
1775 let trimmed = new_content.trim_end_matches('\n').to_string();
1776 app.editor.borrow_mut().editor.set_text(&trimmed);
1777 app.editor.borrow_mut().check_autocomplete();
1778 }
1779 let _ = std::fs::remove_file(&tmp_file);
1780 app.status_text = Some("Editor closed".into());
1781 }
1782 Ok(_) => {
1783 let _ = std::fs::remove_file(&tmp_file);
1784 app.status_text = Some("Editor exited with non-zero status".into());
1785 }
1786 Err(e) => {
1787 let _ = std::fs::remove_file(&tmp_file);
1788 app.status_text = Some(format!("Failed to launch editor: {}", e));
1789 }
1790 }
1791}
1792
1793fn handle_compact_toggle(app: &mut App) {
1796 app.auto_compact = !app.auto_compact;
1797 app.footer.borrow_mut().set_auto_compact(app.auto_compact);
1798
1799 if let Some(ref mut s) = app.session {
1801 s.set_auto_compact(app.auto_compact);
1802 }
1803
1804 app.settings.set_auto_compact(Some(app.auto_compact));
1806 if let Err(e) = app.settings.save() {
1807 eprintln!("Warning: failed to save auto_compact setting: {}", e);
1808 }
1809
1810 app.status_text = Some(if app.auto_compact {
1811 "Auto-compact: on".into()
1812 } else {
1813 "Auto-compact: off".into()
1814 });
1815}
1816
1817pub fn handle_follow_up(app: &mut App, text: String) {
1821 let trimmed = text.trim().to_string();
1822 if trimmed.is_empty() {
1823 return;
1824 }
1825
1826 if app.is_streaming && app.agent.as_ref().is_some_and(|a| a.is_streaming()) {
1827 let follow_msg = user_agent_message(&trimmed);
1828 if let Some(ref agent) = app.agent {
1829 agent.follow_up(follow_msg);
1830 app.status_text = Some("Follow-up queued — will send when agent finishes".into());
1831 }
1832 } else {
1833 if app.is_streaming {
1835 app.is_streaming = false;
1836 }
1837 submit_message(app, trimmed);
1838 }
1839}
1840
1841fn interrupt_streaming(app: &mut App) {
1843 if let Some(ref agent) = app.agent {
1845 agent.abort();
1846 }
1847 if let Some(handle) = app.forward_handle.take() {
1849 handle.abort();
1850 }
1851 if let Some(handle) = app.bash_abort_handle.take() {
1852 handle.abort();
1853 }
1854 app.agent = None;
1857 app.is_streaming = false;
1858 app.working.stop();
1859 app.footer.borrow_mut().set_streaming(false);
1860
1861 if let Some(ref s) = app.session {
1863 let ctx = s.session().build_session_context();
1864 let mut chat = app.chat_container.borrow_mut();
1865 rebuild_chat_from_messages(
1866 &mut chat,
1867 &ctx.messages,
1868 &app.cwd.to_string_lossy(),
1869 app.hide_thinking,
1870 app.collapse_tool_output,
1871 &app.extensions,
1872 );
1873 }
1874
1875 app.status_text = Some("Interrupted".into());
1876}
1877
1878fn handle_login(app: &mut App, provider: &str, api_key: Option<&str>) {
1883 let provider = if provider.is_empty() {
1884 "opencode-go"
1885 } else {
1886 provider
1887 };
1888 if let Some(key) = api_key {
1889 match auth::login(provider, key) {
1890 Ok(_) => {
1891 app.refresh_registry();
1892 complete_login(
1894 app,
1895 provider,
1896 crate::agent::ui::components::oauth_selector::AuthType::ApiKey,
1897 );
1898 }
1899 Err(e) => chat_info(app, format!("Login failed: {}", e)),
1900 }
1901 } else {
1902 chat_info(app, format!("Usage: /login {} <api-key>", provider));
1903 }
1904}
1905
1906fn handle_logout(app: &mut App, provider: Option<&str>) {
1908 match auth::logout(provider) {
1909 Ok(true) => {
1910 let msg = provider
1911 .map(|p| format!("Logged out from {}", p))
1912 .unwrap_or_else(|| "Logged out from all providers".into());
1913 chat_info(app, msg);
1914 }
1915 Ok(false) => {
1916 let msg = provider
1917 .map(|p| format!("No credentials for {}", p))
1918 .unwrap_or_else(|| "No credentials found".into());
1919 chat_info(app, msg);
1920 }
1921 Err(e) => {
1922 chat_info(app, format!("Logout failed: {}", e));
1923 }
1924 }
1925}
1926
1927fn show_login_provider_selector(app: &mut App, tui: &mut TUI, auth_type: Option<AuthType>) {
1930 use crate::agent::ui::components::oauth_selector::{
1931 AuthSelectorProvider, AuthType, OAuthSelector, SelectorMode,
1932 };
1933
1934 let all_providers = app.registry.list_providers();
1935
1936 let mut providers: Vec<AuthSelectorProvider> = Vec::new();
1938
1939 for (id, name) in all_providers {
1941 let is_oauth_provider = crate::provider::oauth::get(&id).is_some();
1942 match auth_type {
1943 Some(AuthType::ApiKey) => {
1944 if !is_oauth_provider {
1946 providers.push(AuthSelectorProvider {
1947 id,
1948 name,
1949 auth_type: AuthType::ApiKey,
1950 });
1951 }
1952 }
1953 Some(AuthType::OAuth) => {
1954 if is_oauth_provider {
1956 providers.push(AuthSelectorProvider {
1957 id,
1958 name,
1959 auth_type: AuthType::OAuth,
1960 });
1961 }
1962 }
1963 None => {
1964 providers.push(AuthSelectorProvider {
1965 id,
1966 name,
1967 auth_type: if is_oauth_provider {
1968 AuthType::OAuth
1969 } else {
1970 AuthType::ApiKey
1971 },
1972 });
1973 }
1974 }
1975 }
1976
1977 if auth_type != Some(AuthType::ApiKey) {
1979 for oauth_id in crate::provider::oauth::list_ids() {
1980 if !providers.iter().any(|p| p.id == oauth_id)
1981 && let Some(provider) = crate::provider::oauth::get(&oauth_id)
1982 {
1983 providers.push(AuthSelectorProvider {
1984 id: oauth_id,
1985 name: provider.name().to_string(),
1986 auth_type: AuthType::OAuth,
1987 });
1988 }
1989 }
1990 }
1991
1992 providers.sort_by_key(|a| a.name.to_lowercase());
1994
1995 if providers.is_empty() {
1996 app.status_text = Some(match auth_type {
1997 Some(AuthType::OAuth) => "No subscription providers available.".into(),
1998 Some(AuthType::ApiKey) => "No API key providers available.".into(),
1999 None => "No providers available.".into(),
2000 });
2001 return;
2002 }
2003
2004 let signal = app.overlay_result_signal.clone();
2005 let mut selector = OAuthSelector::new(
2006 providers,
2007 |provider_id| app.registry.auth_status_for_provider(provider_id),
2008 SelectorMode::Login,
2009 );
2010
2011 selector.on_select(move |provider_id: String| {
2012 *signal.borrow_mut() = Some(OverlayResult::LoginProviderSelected(provider_id));
2013 });
2014 selector.on_cancel(|| {});
2015
2016 tui.show_top_overlay(Box::new(selector));
2017}
2018
2019fn show_api_key_login_dialog(app: &mut App, tui: &mut TUI, provider_id: &str) {
2022 use crate::agent::ui::components::LoginDialog;
2023
2024 let provider_name = app
2026 .registry
2027 .list_providers()
2028 .into_iter()
2029 .find(|(id, _)| id == provider_id)
2030 .map(|(_, name)| name)
2031 .unwrap_or_else(|| provider_id.to_string());
2032
2033 let mut dialog = LoginDialog::new(provider_id.to_string(), provider_name.clone());
2034
2035 let signal = app.overlay_result_signal.clone();
2036 let provider_id_clone = provider_id.to_string();
2037
2038 dialog.on_submit(move |api_key: String| {
2039 *signal.borrow_mut() = Some(OverlayResult::LoginApiKeyProvided {
2040 provider: provider_id_clone,
2041 key: api_key,
2042 });
2043 });
2044
2045 dialog.on_cancel(|| {});
2046
2047 dialog.show_prompt("Enter API key:", Some("sk-..."));
2048
2049 tui.show_top_overlay(Box::new(dialog));
2050}
2051
2052fn show_oauth_login_dialog(app: &mut App, tui: &mut TUI, provider_id: &str) {
2055 let provider_name = app
2056 .registry
2057 .list_providers()
2058 .into_iter()
2059 .find(|(id, _)| id == provider_id)
2060 .map(|(_, name)| name)
2061 .unwrap_or_else(|| {
2062 crate::provider::oauth::get(provider_id)
2063 .map(|p| p.name().to_string())
2064 .unwrap_or_else(|| provider_id.to_string())
2065 });
2066
2067 app.status_text = Some(format!("Starting OAuth login for {}…", provider_name));
2068 tui.pop_overlay(); let tx = app.event_tx.clone();
2073 let pid = provider_id.to_string();
2074 let pname = provider_name.clone();
2075
2076 let tx2 = tx.clone();
2077 let tx3 = tx.clone();
2078 let tx4 = tx.clone();
2079
2080 app.pending_oauth_provider = Some(pid.clone());
2081
2082 let handle = tokio::spawn(async move {
2083 let oauth_provider = match crate::provider::oauth::get(&pid) {
2084 Some(p) => p,
2085 None => {
2086 let _ = tx.send(yoagent::types::AgentEvent::ProgressMessage {
2087 tool_call_id: String::new(),
2088 tool_name: String::new(),
2089 text: format!(
2090 "OAuth login failed: No OAuth provider registered for '{}'",
2091 pid
2092 ),
2093 });
2094 return;
2095 }
2096 };
2097
2098 let mut callbacks = crate::provider::oauth::OAuthLoginCallbacks {
2099 on_device_code: Box::new(move |info: crate::provider::oauth::DeviceCodeInfo| {
2100 let device_msg = format!(
2101 "Open {} and enter code: {}",
2102 info.verification_uri, info.user_code
2103 );
2104 let _ = tx.send(yoagent::types::AgentEvent::ProgressMessage {
2106 tool_call_id: String::new(),
2107 tool_name: String::new(),
2108 text: device_msg,
2109 });
2110 }),
2111 on_prompt: Box::new(
2112 move |prompt: crate::provider::oauth::OAuthPrompt| match prompt {
2113 crate::provider::oauth::OAuthPrompt::Text {
2114 message,
2115 placeholder: _,
2116 allow_empty: _,
2117 } => {
2118 let _ = tx2.send(yoagent::types::AgentEvent::ProgressMessage {
2120 tool_call_id: String::new(),
2121 tool_name: String::new(),
2122 text: format!("{} (empty = github.com)", message),
2123 });
2124 Ok(String::new())
2127 }
2128 },
2129 ),
2130 on_progress: Box::new(move |msg: String| {
2131 let _ = tx3.send(yoagent::types::AgentEvent::ProgressMessage {
2132 tool_call_id: String::new(),
2133 tool_name: String::new(),
2134 text: format!("[OAuth] {}", msg),
2135 });
2136 }),
2137 signal: None,
2138 };
2139
2140 match oauth_provider.login(&mut callbacks).await {
2141 Ok(credentials) => {
2142 let cred = crate::auth::AuthCredential::Oauth {
2143 access: credentials.access.clone(),
2144 refresh: Some(credentials.refresh.clone()),
2145 expires: Some(credentials.expires),
2146 enterprise_url: credentials.enterprise_url.clone(),
2147 };
2148 match crate::auth::login_oauth(&pid, &cred) {
2149 Ok(_) => {
2150 let _ = tx4.send(yoagent::types::AgentEvent::ProgressMessage {
2151 tool_call_id: String::new(),
2152 tool_name: String::new(),
2153 text: format!("✓ Logged in to {} via OAuth", pname),
2154 });
2155 }
2156 Err(e) => {
2157 let _ = tx4.send(yoagent::types::AgentEvent::ProgressMessage {
2158 tool_call_id: String::new(),
2159 tool_name: String::new(),
2160 text: format!("Failed to save OAuth credentials: {}", e),
2161 });
2162 }
2163 }
2164 }
2165 Err(e) => {
2166 let _ = tx4.send(yoagent::types::AgentEvent::ProgressMessage {
2167 tool_call_id: String::new(),
2168 tool_name: String::new(),
2169 text: format!("OAuth login failed: {}", e),
2170 });
2171 }
2172 }
2173 });
2174 app.oauth_join_handle = Some(handle);
2175}
2176
2177fn show_auth_type_selector(app: &mut App, tui: &mut TUI) {
2180 let signal = app.overlay_result_signal.clone();
2182 let _theme = crate::agent::ui::theme::current_theme().clone();
2183
2184 let mut items = vec![crate::tui::components::select_list::SelectItem::new(
2185 "api_key",
2186 "Use an API key",
2187 )];
2188 let has_oauth = !crate::provider::oauth::list_ids().is_empty();
2190 if has_oauth {
2191 items.push(crate::tui::components::select_list::SelectItem::new(
2192 "oauth",
2193 "Use a subscription",
2194 ));
2195 }
2196
2197 let filtered_indices: Vec<usize> = (0..items.len()).collect();
2198 let selected_index: usize = 0;
2199
2200 struct AuthTypeOverlay {
2201 items: Vec<crate::tui::components::select_list::SelectItem>,
2202 selected_index: usize,
2203 filtered_indices: Vec<usize>,
2204 signal: std::rc::Rc<std::cell::RefCell<Option<OverlayResult>>>,
2205 }
2206
2207 impl crate::tui::Component for AuthTypeOverlay {
2208 fn render(&mut self, width: usize) -> Vec<String> {
2209 let theme = crate::agent::ui::theme::current_theme();
2210 let mut lines = Vec::new();
2211
2212 lines.push(theme.dim(&"─".repeat(width.saturating_sub(2))));
2213 lines.push(String::new());
2214 lines.push(format!(
2215 " {}",
2216 theme.bold(&theme.fg_key(ThemeKey::Accent, "Select authentication method:"))
2217 ));
2218 lines.push(String::new());
2219
2220 for (i, &item_idx) in self.filtered_indices.iter().enumerate() {
2221 let item = &self.items[item_idx];
2222 let is_selected = i == self.selected_index;
2223 let prefix = if is_selected {
2224 theme.fg_key(ThemeKey::Accent, "→ ")
2225 } else {
2226 " ".to_string()
2227 };
2228 let text = if is_selected {
2229 theme.fg_key(ThemeKey::Accent, &item.label)
2230 } else {
2231 theme.fg_key(ThemeKey::Text, &item.label)
2232 };
2233 lines.push(format!("{}{}", prefix, text));
2234 }
2235
2236 lines.push(String::new());
2237 lines.push(format!(" {}", theme.dim("Enter: select · Esc: cancel")));
2238 lines.push(String::new());
2239 lines.push(theme.dim(&"─".repeat(width.saturating_sub(2))));
2240
2241 lines
2242 }
2243
2244 fn handle_input(&mut self, key: &crossterm::event::KeyEvent) -> bool {
2245 let kb = crate::tui::keybindings::get_keybindings();
2246
2247 if kb.matches(key, crate::tui::keybindings::ACTION_SELECT_UP) {
2248 if self.filtered_indices.is_empty() {
2249 return true;
2250 }
2251 self.selected_index = if self.selected_index == 0 {
2252 self.filtered_indices.len() - 1
2253 } else {
2254 self.selected_index - 1
2255 };
2256 return true;
2257 }
2258
2259 if kb.matches(key, crate::tui::keybindings::ACTION_SELECT_DOWN) {
2260 if self.filtered_indices.is_empty() {
2261 return true;
2262 }
2263 self.selected_index = if self.selected_index >= self.filtered_indices.len() - 1 {
2264 0
2265 } else {
2266 self.selected_index + 1
2267 };
2268 return true;
2269 }
2270
2271 if kb.matches(key, crate::tui::keybindings::ACTION_SELECT_CONFIRM) {
2272 if let Some(&idx) = self.filtered_indices.get(self.selected_index) {
2273 let value = self.items[idx].value.clone();
2274 let auth_type = match value.as_str() {
2275 "oauth" => AuthType::OAuth,
2276 _ => AuthType::ApiKey,
2277 };
2278 *self.signal.borrow_mut() =
2279 Some(OverlayResult::LoginAuthTypeSelected(auth_type));
2280 }
2281 return true;
2282 }
2283
2284 if kb.matches(key, crate::tui::keybindings::ACTION_SELECT_CANCEL) {
2285 return true;
2287 }
2288
2289 false
2290 }
2291 }
2292
2293 let overlay = AuthTypeOverlay {
2294 items,
2295 selected_index,
2296 filtered_indices,
2297 signal: signal.clone(),
2298 };
2299
2300 tui.show_top_overlay(Box::new(overlay));
2301}
2302
2303fn show_auth_type_or_provider_selector(app: &mut App, tui: &mut TUI) {
2306 let providers = app.registry.list_providers();
2307 if providers.is_empty() {
2308 app.status_text = Some("No providers available for login.".into());
2309 return;
2310 }
2311 let has_oauth = !crate::provider::oauth::list_ids().is_empty();
2313 let has_api_key = providers.iter().any(|(_, _)| true);
2314 if has_oauth && has_api_key {
2315 show_auth_type_selector(app, tui);
2316 } else if has_oauth {
2317 show_login_provider_selector(app, tui, Some(AuthType::OAuth));
2318 } else {
2319 show_login_provider_selector(app, tui, Some(AuthType::ApiKey));
2320 }
2321}
2322
2323fn show_logout_provider_selector(app: &mut App, tui: &mut TUI) {
2326 use crate::agent::ui::components::oauth_selector::{
2327 AuthSelectorProvider, AuthType, OAuthSelector, SelectorMode,
2328 };
2329
2330 let logged_in = auth::list_logged_in().unwrap_or_default();
2332
2333 if logged_in.is_empty() {
2334 app.status_text = Some(
2335 "No stored credentials to remove. /logout only removes credentials saved by /login; \
2336 environment variables and models.json config are unchanged."
2337 .into(),
2338 );
2339 return;
2340 }
2341
2342 let mut providers: Vec<AuthSelectorProvider> = logged_in
2343 .into_iter()
2344 .filter_map(|id| {
2345 app.registry
2346 .list_providers()
2347 .into_iter()
2348 .find(|(pid, _)| pid == &id)
2349 .map(|(pid, name)| AuthSelectorProvider {
2350 id: pid,
2351 name,
2352 auth_type: AuthType::ApiKey,
2353 })
2354 })
2355 .collect();
2356
2357 providers.sort_by_key(|a| a.name.to_lowercase());
2359
2360 if providers.is_empty() {
2361 app.status_text = Some("No registered providers with stored credentials.".into());
2363 return;
2364 }
2365
2366 let signal = app.overlay_result_signal.clone();
2367 let mut selector = OAuthSelector::new(
2368 providers,
2369 |provider_id| app.registry.auth_status_for_provider(provider_id),
2370 SelectorMode::Logout,
2371 );
2372
2373 selector.on_select(move |provider_id: String| {
2374 *signal.borrow_mut() = Some(OverlayResult::LogoutProviderSelected(provider_id));
2375 });
2376 selector.on_cancel(|| {});
2377
2378 tui.show_top_overlay(Box::new(selector));
2379}
2380
2381fn complete_login(app: &mut App, provider_id: &str, _auth_type: AuthType) {
2384 let available_models = app.registry.list_model_provider_tuples();
2386 let provider_models: Vec<&str> = available_models
2387 .iter()
2388 .filter(|(pid, _, _)| pid == provider_id)
2389 .map(|(_, mid, _)| mid.as_str())
2390 .collect();
2391
2392 if provider_models.is_empty() {
2393 app.status_text = Some(format!(
2394 "Saved API key for {provider_id}. No models available for this provider. Use /model to select a model."
2395 ));
2396 return;
2397 }
2398
2399 let current_provider = app
2401 .registry
2402 .provider_for_model(&app.model, Some(&app.current_provider))
2403 .unwrap_or_default();
2404
2405 if current_provider != provider_id || !app.available_models.contains(&app.model) {
2406 let first_model = provider_models[0];
2407 app.model = first_model.to_string();
2408 app.current_provider = provider_id.to_string();
2409 let model = app.model.clone();
2410 app.record_model_change(&model);
2411 app.status_text = Some(format!(
2412 "Saved API key for {provider_id}. Selected {first_model}."
2413 ));
2414 } else {
2415 app.status_text = Some(format!("Saved API key for {provider_id}."));
2416 }
2417}
2418
2419fn open_model_selector(app: &mut App, tui: &mut TUI) {
2421 let current = app.model.clone();
2422
2423 let all_tuples: Vec<(String, String, String)> = app.registry.list_model_provider_tuples();
2426 let all_models: Vec<(String, String, String)> = all_tuples
2427 .into_iter()
2428 .filter(|(provider, _, _)| app.registry.provider_has_auth(provider))
2429 .collect();
2430
2431 let scoped_ids = app.scoped_model_ids.clone().unwrap_or_default();
2432
2433 let signal = app.overlay_result_signal.clone();
2434 let current_provider = app
2435 .registry
2436 .provider_for_model(¤t, Some(&app.current_provider))
2437 .unwrap_or_else(|| "unknown".to_string());
2438 let current_full_id = format!("{}/{}", current_provider, current);
2439
2440 let callbacks = crate::agent::ui::model_selector::ModelSelectorCallbacks {
2441 on_select: Box::new({
2442 let signal = signal.clone();
2443 move |full_id: String| {
2444 *signal.borrow_mut() = Some(OverlayResult::ModelSelected(full_id));
2445 }
2446 }),
2447 on_cancel: Box::new(|| {}), };
2449
2450 let selector = crate::agent::ui::model_selector::ModelSelector::new(
2451 all_models,
2452 scoped_ids,
2453 current_full_id,
2454 callbacks,
2455 );
2456 tui.show_top_overlay(Box::new(selector));
2457}
2458
2459fn open_scoped_models_selector(app: &mut App, tui: &mut TUI) {
2461 use crate::agent::ui::components::scoped_models_selector::{
2462 ModelsCallbacks, ModelsConfig, ScopedModelsSelector,
2463 };
2464
2465 let all_tuples: Vec<(String, String, String)> = app.registry.list_model_provider_tuples();
2467 let all_models: Vec<(String, String, String)> = all_tuples
2468 .into_iter()
2469 .filter(|(provider, _, _)| app.registry.provider_has_auth(provider))
2470 .collect();
2471
2472 let current_enabled = app.scoped_model_ids.clone();
2473 let change_signal = app.pending_scoped_ids.clone();
2474 let close_signal = app.overlay_result_signal.clone();
2475
2476 let callbacks = ModelsCallbacks {
2477 on_change: Box::new(move |enabled_ids: Option<Vec<String>>| {
2478 *change_signal.borrow_mut() = Some(enabled_ids.unwrap_or_default());
2480 }),
2481 on_persist: Box::new({
2482 let cs = close_signal.clone();
2483 move |enabled_ids: Option<Vec<String>>| {
2484 *cs.borrow_mut() = Some(OverlayResult::ScopedModelsAccepted(enabled_ids));
2485 }
2486 }),
2487 on_cancel: Box::new(move || {
2488 *close_signal.borrow_mut() = Some(OverlayResult::ScopedModelsCancelled);
2489 }),
2490 };
2491
2492 let config = ModelsConfig {
2493 all_models,
2494 enabled_model_ids: current_enabled,
2495 };
2496
2497 let selector = ScopedModelsSelector::new(config, callbacks);
2498 tui.show_top_overlay(Box::new(selector));
2499}
2500
2501fn show_help_overlay(app: &mut App, tui: &mut TUI) {
2502 let mut overlay = crate::agent::ui::help::HelpOverlay::new(&app.theme);
2503 overlay.set_commands(app.commands.clone());
2504 tui.show_overlay(Box::new(overlay), Default::default());
2505}
2506
2507fn submit_message(app: &mut App, message: String) {
2512 app.scroll_offset = 0;
2513 let trimmed = message.trim().to_string();
2514
2515 if trimmed.is_empty() {
2517 return;
2518 }
2519
2520 let after_skill = if trimmed.starts_with("/skill:") {
2522 expand_skill_command(&trimmed, &app.skills)
2523 } else {
2524 trimmed.clone()
2525 };
2526
2527 let expanded =
2529 crate::agent::prompt_templates::expand_prompt_template(&after_skill, &app.prompt_templates);
2530
2531 if expanded != after_skill || after_skill != trimmed {
2533 if app.is_streaming && app.agent.as_ref().is_some_and(|a| a.is_streaming()) {
2535 let steer_msg = user_agent_message(&expanded);
2536 if let Some(ref agent) = app.agent {
2537 agent.steer(steer_msg);
2538 app.status_text = Some("Skill/template steering message sent".into());
2539 }
2540 return;
2541 }
2542 if app.is_streaming {
2543 app.is_streaming = false;
2545 app.working.stop();
2546 app.footer.borrow_mut().set_streaming(false);
2547 }
2548 app.pending_submit = Some(expanded);
2549 return;
2550 }
2551
2552 if trimmed.starts_with('/') {
2554 handle_slash_command(app, &trimmed);
2555 return;
2556 }
2557
2558 if let Some((cmd, _exclude)) = parse_bang_command(&trimmed) {
2560 handle_bang_command(app, cmd);
2561 return;
2562 }
2563
2564 if app.is_streaming {
2565 if app.agent.as_ref().is_some_and(|a| a.is_streaming()) {
2570 let steer_msg = user_agent_message(&trimmed);
2571 if let Some(ref agent) = app.agent {
2572 agent.steer(steer_msg);
2573 app.status_text = Some("Steering message sent — will be processed next".into());
2574 }
2575 if let Some(ref mut s) = app.session {
2577 s.reset_overflow_recovery();
2578 }
2579 return; } else {
2581 app.is_streaming = false;
2584 app.working.stop();
2585 app.footer.borrow_mut().set_streaming(false);
2586 }
2587 }
2588
2589 if let Some(ref mut s) = app.session {
2591 s.reset_overflow_recovery();
2592 }
2593
2594 app.pending_submit = Some(trimmed);
2596}
2597
2598#[allow(clippy::too_many_arguments)]
2601fn build_fresh_agent(
2602 registry: &ProviderRegistry,
2603 model: &str,
2604 api_key: &str,
2605 system_prompt: &str,
2606 thinking_level: yoagent::types::ThinkingLevel,
2607 messages: Vec<yoagent::types::AgentMessage>,
2608 extensions: &[Box<dyn Extension>],
2609 default_provider: Option<&str>,
2610) -> yoagent::agent::Agent {
2611 use yoagent::provider::model::ApiProtocol;
2612
2613 let resolved = registry.resolve(model, default_provider).ok();
2614 let mc = resolved
2615 .as_ref()
2616 .map(|r| r.model_config.clone())
2617 .unwrap_or_else(|| crate::agent::base_model_config(model));
2618 let api_key = resolved
2619 .as_ref()
2620 .map(|r| r.api_key.as_str())
2621 .unwrap_or(api_key);
2622
2623 let tools: Vec<Box<dyn yoagent::types::AgentTool>> = extensions
2624 .iter()
2625 .flat_map(|ext| ext.tools())
2626 .map(|twm| Box::new(twm) as Box<dyn yoagent::types::AgentTool>)
2627 .collect();
2628
2629 let agent = match mc.api {
2630 ApiProtocol::OpenAiCompletions => {
2631 yoagent::agent::Agent::new(crate::provider::openai_compat::RabOpenAiCompatProvider)
2632 }
2633 ApiProtocol::AnthropicMessages => {
2634 yoagent::agent::Agent::new(crate::provider::anthropic::RabAnthropicProvider)
2635 }
2636 ApiProtocol::OpenAiResponses => {
2637 yoagent::agent::Agent::new(yoagent::provider::OpenAiResponsesProvider)
2638 }
2639 ApiProtocol::GoogleGenerativeAi => {
2640 yoagent::agent::Agent::new(yoagent::provider::GoogleProvider)
2641 }
2642 _ => yoagent::agent::Agent::new(yoagent::provider::OpenAiCompatProvider),
2643 };
2644
2645 agent
2646 .with_model(model)
2647 .with_api_key(api_key)
2648 .with_model_config(mc)
2649 .with_system_prompt(system_prompt)
2650 .with_thinking(thinking_level)
2651 .with_messages(messages)
2652 .with_tools(tools)
2653 .without_context_management()
2654}
2655
2656fn map_thinking_level(level: Option<&str>) -> yoagent::types::ThinkingLevel {
2658 match level {
2659 Some("off") => yoagent::types::ThinkingLevel::Off,
2660 Some("low") => yoagent::types::ThinkingLevel::Low,
2661 Some("medium") => yoagent::types::ThinkingLevel::Medium,
2662 Some("high") | Some("xhigh") => yoagent::types::ThinkingLevel::High,
2663 _ => yoagent::types::ThinkingLevel::High,
2664 }
2665}
2666
2667async fn start_agent_loop(app: &mut App, message: String) {
2675 if app.session.is_none() {
2676 return;
2677 }
2678
2679 app.is_streaming = true;
2680 app.working.start();
2681 app.footer.borrow_mut().set_streaming(true);
2682
2683 let thinking = map_thinking_level(app.thinking_level.as_deref());
2684
2685 let msgs = app
2689 .session
2690 .as_ref()
2691 .map(|s| s.session().build_session_context().messages)
2692 .unwrap_or_default();
2693
2694 let model = app.model.clone();
2696 app.record_model_change(&model);
2697 if let Some(ref mut session) = app.session {
2698 session.on_thinking_level_change(app.thinking_level.as_deref().unwrap_or("off"));
2699 }
2700
2701 let agent: &mut yoagent::agent::Agent = match &mut app.agent {
2702 Some(existing) => {
2703 existing
2707 }
2708 None => {
2709 let preferred = if !app.current_provider.is_empty() {
2710 Some(app.current_provider.as_str())
2711 } else {
2712 app.settings.default_provider.as_deref()
2713 };
2714 app.agent = Some(build_fresh_agent(
2715 &app.registry,
2716 &app.model,
2717 &app.api_key,
2718 &app.system_prompt,
2719 thinking,
2720 msgs,
2721 &app.extensions,
2722 preferred,
2723 ));
2724 app.agent.as_mut().unwrap()
2726 }
2727 };
2728
2729 let mut rx = agent.prompt(message).await;
2732
2733 let tx = app.event_tx.clone();
2736 let handle = tokio::spawn(async move {
2737 while let Some(event) = rx.recv().await {
2738 if tx.send(event).is_err() {
2739 break;
2740 }
2741 }
2742 });
2743 app.forward_handle = Some(handle);
2744}
2745
2746async fn handle_compact_command(app: &mut App, custom_instructions: Option<String>) {
2749 if app.session.is_none() {
2750 chat_info(app, "No active session to compact".to_string());
2751 return;
2752 }
2753
2754 let agent_session = app.session.as_mut().unwrap();
2755
2756 app.working.start();
2757
2758 match agent_session
2759 .run_manual_compact(custom_instructions.as_deref())
2760 .await
2761 {
2762 Ok(_summary) => {
2763 app.working.stop();
2764 app.status_text = None;
2765 app.rebuild_from_session_context();
2766 show_status(app, "Compaction completed".to_string());
2767 }
2768 Err(e) => {
2769 app.working.stop();
2770 app.status_text = None;
2771 chat_info(app, format!("Compaction failed: {}", e));
2772 }
2773 }
2774}
2775
2776async fn handle_auto_compact(app: &mut App) {
2780 if app.session.is_none() {
2781 return;
2782 }
2783
2784 let agent_session = app.session.as_mut().unwrap();
2785
2786 match agent_session.check_auto_compact().await {
2787 Ok(true) => {
2788 app.rebuild_from_session_context();
2789 if let Some(ref s) = app.session {
2791 app.footer.borrow_mut().refresh_from_session(s.session());
2792 }
2793 app.status_text = Some("Auto-compaction completed".to_string());
2794 }
2795 Ok(false) => {
2796 }
2798 Err(e) => {
2799 eprintln!("Warning: Auto-compaction failed: {}", e);
2800 app.status_text = Some(format!("Auto-compaction skipped: {}", e));
2801 }
2802 }
2803}
2804
2805fn handle_session_picker_input(app: &mut App, key: &crossterm::event::KeyEvent) {
2807 use crossterm::event::KeyCode;
2808
2809 let Some(ref mut picker) = app.session_picker else {
2810 return;
2811 };
2812
2813 match key.code {
2814 KeyCode::Esc => {
2815 app.session_picker = None;
2816 app.status_text = None;
2817 }
2818 KeyCode::Enter => {
2819 if let Some(path) = picker.selected_path() {
2820 let path = path.clone();
2821 app.session_picker = None;
2822 app.status_text = None;
2823 app.pending_command_result = Some(CommandResult::SessionSwitched { path });
2825 }
2826 }
2827 KeyCode::Up => {
2828 picker.select_prev();
2829 }
2830 KeyCode::Down => {
2831 picker.select_next();
2832 }
2833 KeyCode::Char('/') => {
2834 picker.set_filter("");
2835 }
2836 KeyCode::Char(c) => {
2837 let mut filter = picker.filter().to_string();
2838 filter.push(c);
2839 picker.set_filter(&filter);
2840 }
2841 KeyCode::Backspace => {
2842 let mut filter = picker.filter().to_string();
2843 filter.pop();
2844 picker.set_filter(&filter);
2845 }
2846 _ => {}
2847 }
2848}
2849
2850fn handle_slash_command(app: &mut App, input: &str) {
2855 let (cmd_name, args) = match input.split_once(' ') {
2856 Some((cmd, rest)) => (cmd.trim_start_matches('/'), rest),
2857 None => (input.trim_start_matches('/'), ""),
2858 };
2859
2860 for ext in app.extensions.iter() {
2862 for cmd in ext.commands() {
2863 if cmd.name == cmd_name {
2864 let result = cmd.handler.execute(args);
2867 match result {
2868 Ok(result) => {
2869 drop((ext, cmd));
2871 handle_command_result(app, result);
2872 return;
2873 }
2874 Err(e) => {
2875 drop((ext, cmd));
2876 chat_info(app, format!("Error executing /{}: {}", cmd_name, e));
2877 return;
2878 }
2879 }
2880 }
2881 }
2882 }
2883
2884 let available: Vec<&str> = app.commands.iter().map(|(n, _)| n.as_str()).collect();
2886 app.status_text = Some(format!(
2887 "Unknown command: /{}. Available: {}",
2888 cmd_name,
2889 available.join(", ")
2890 ));
2891}
2892
2893fn handle_command_result(app: &mut App, result: CommandResult) {
2897 match result {
2898 CommandResult::Info(msg) => {
2899 chat_info(app, msg.clone());
2900 }
2901 CommandResult::Quit => {
2902 app.should_quit = true;
2903 }
2904 CommandResult::ModelChanged(model) => {
2905 app.model = model.clone();
2906 app.current_provider = app
2907 .registry
2908 .provider_for_model(&model, Some(&app.current_provider))
2909 .unwrap_or_default();
2910 app.record_model_change(&model);
2911 app.status_text = Some(format!("Model: {}", model));
2912 }
2913 CommandResult::ShowHelp => {
2914 app.pending_command_result = Some(result);
2916 }
2917 CommandResult::Reloaded => {
2918 app.refresh_registry();
2919
2920 {
2922 let models = app.registry.list_models();
2923 app.available_models = models.clone();
2924 for ext in app.extensions.iter() {
2925 if let Some(cmd) = ext
2926 .as_any()
2927 .downcast_ref::<crate::builtin::commands::CommandsExtension>()
2928 {
2929 cmd.set_available_models(models.clone());
2930 break;
2931 }
2932 }
2933 }
2934
2935 for ext in app.extensions.iter() {
2937 ext.on_session_shutdown("reload");
2938 }
2939
2940 let mut reload_parts: Vec<&str> = Vec::new();
2942 match app.settings.reload(&app.cwd) {
2943 Err(e) => {
2944 app.status_text = Some(format!("Failed to reload settings: {}", e));
2945 }
2946 Ok(()) => {
2947 reload_parts.push("settings");
2948 if let Some(level) = app.settings.default_thinking_level.clone() {
2950 app.thinking_level = Some(level.clone());
2951 if let Some(ref mut s) = app.session {
2952 s.on_thinking_level_change(&level);
2953 }
2954 if let Some(ref s) = app.session {
2955 app.footer.borrow_mut().refresh_from_session(s.session());
2956 }
2957 }
2958 app.hide_thinking = app.settings.hide_thinking.unwrap_or(true);
2959 app.propagate_hide_thinking();
2960 app.editor.borrow_mut().update_border_color(
2961 app.thinking_level.as_deref(),
2962 &app.theme as &dyn crate::tui::Theme,
2963 );
2964
2965 app.auto_compact = app.settings.auto_compact.unwrap_or(true);
2967 if let Some(ref mut s) = app.session {
2968 s.set_auto_compact(app.auto_compact);
2969 }
2970 app.footer.borrow_mut().set_auto_compact(app.auto_compact);
2971
2972 app.collapse_tool_output = app.settings.collapse_tool_output.unwrap_or(false);
2974 app.tools_expanded = !app.collapse_tool_output;
2975
2976 if let Some(ref theme_name) = app.settings.theme
2978 && crate::agent::ui::theme::set_theme(theme_name).is_ok()
2979 {
2980 app.theme = crate::agent::ui::theme::current_theme().clone();
2981 reload_parts.push("theme");
2982 }
2983 }
2984 }
2985
2986 let mut kb = crate::tui::keybindings::Keybindings::with_defaults();
2988 if let Some(home) = directories::BaseDirs::new()
2989 .map(|d| d.home_dir().join(".rab").join("keybindings.json"))
2990 && home.exists()
2991 {
2992 match crate::tui::keybindings::Keybindings::load(&home) {
2993 Ok(custom) => kb.merge(custom),
2994 Err(e) => {
2995 app.status_text = Some(format!("Failed to load keybindings: {}", e));
2996 }
2997 }
2998 }
2999 crate::tui::keybindings::init_keybindings(kb);
3000 reload_parts.push("keybindings");
3001
3002 let new_skill_set =
3004 yoagent::skills::SkillSet::load(&app.skill_dirs).unwrap_or_default();
3005 app.skills = new_skill_set.skills().to_vec();
3006 reload_parts.push("skills");
3007
3008 app.prompt_templates =
3010 crate::agent::prompt_templates::load_prompt_templates(&app.prompt_template_dirs);
3011 if !app.prompt_template_dirs.is_empty() {
3013 reload_parts.push("prompts");
3014 }
3015
3016 let context_files =
3018 crate::agent::context_files::load_context_files(&app.cwd, &app.agent_dir);
3019 let custom_system_md = {
3021 let project_path = app.cwd.join(".rab").join("SYSTEM.md");
3022 if project_path.exists() {
3023 std::fs::read_to_string(&project_path).ok()
3024 } else {
3025 let global_path = app.agent_dir.join("SYSTEM.md");
3026 if global_path.exists() {
3027 std::fs::read_to_string(&global_path).ok()
3028 } else {
3029 None
3030 }
3031 }
3032 };
3033 let append_system_md = {
3035 let project_path = app.cwd.join(".rab").join("APPEND_SYSTEM.md");
3036 if project_path.exists() {
3037 std::fs::read_to_string(&project_path).ok()
3038 } else {
3039 let global_path = app.agent_dir.join("APPEND_SYSTEM.md");
3040 if global_path.exists() {
3041 std::fs::read_to_string(&global_path).ok()
3042 } else {
3043 None
3044 }
3045 }
3046 };
3047
3048 let all_tools: Vec<crate::agent::extension::ToolDefinition> =
3050 app.extensions.iter().flat_map(|ext| ext.tools()).collect();
3051 let tool_snippets: Vec<crate::agent::ToolSnippet> = all_tools
3052 .iter()
3053 .map(|twm| crate::agent::ToolSnippet {
3054 name: twm.name().to_string(),
3055 description: twm.snippet.to_string(),
3056 })
3057 .collect();
3058 let has_read_tool = tool_snippets.iter().any(|t| t.name == "read");
3059
3060 let new_system_prompt = crate::agent::SystemPromptBuilder::new()
3061 .tool_snippets(tool_snippets)
3062 .context_files(context_files.clone())
3063 .custom_prompt(custom_system_md)
3064 .append_prompt(append_system_md)
3065 .skills(new_skill_set)
3066 .has_read_tool(has_read_tool)
3067 .cwd(&app.cwd)
3068 .build();
3069 app.system_prompt = new_system_prompt;
3070
3071 let context_file_list: Vec<String> = context_files
3073 .iter()
3074 .map(|cf| {
3075 let cwd_str = app.cwd.to_string_lossy();
3076 if let Some(rel) = cf.path.to_string_lossy().strip_prefix(&cwd_str as &str) {
3077 if rel.is_empty() {
3078 cf.path.to_string_lossy().to_string()
3079 } else {
3080 format!("./{}", rel.trim_start_matches('/'))
3081 }
3082 } else if let Some(home) =
3083 std::env::var_os("HOME").and_then(|h| h.into_string().ok())
3084 && let Some(rel) = cf.path.to_string_lossy().strip_prefix(&home)
3085 {
3086 if rel.is_empty() {
3087 cf.path.to_string_lossy().to_string()
3088 } else {
3089 format!("~/{}", rel.trim_start_matches('/'))
3090 }
3091 } else {
3092 cf.path.to_string_lossy().to_string()
3093 }
3094 })
3095 .collect();
3096 app.context_files = context_file_list.clone();
3097 {
3099 let skill_names: Vec<String> = app.skills.iter().map(|s| s.name.clone()).collect();
3100 let template_names: Vec<String> = app
3101 .prompt_templates
3102 .iter()
3103 .map(|t| t.name.clone())
3104 .collect();
3105 let extension_names: Vec<String> = app
3106 .extensions
3107 .iter()
3108 .map(|e| e.name().to_string())
3109 .collect();
3110 let theme_names: Vec<String> = crate::agent::ui::theme::get_available_themes()
3111 .into_iter()
3112 .filter(|n| n != "dark" && n != "light")
3113 .collect();
3114 app.header.borrow_mut().set_resource_data(
3115 context_file_list,
3116 skill_names,
3117 template_names,
3118 extension_names,
3119 theme_names,
3120 );
3121 }
3122 reload_parts.push("system prompt");
3123 reload_parts.push("context files");
3124
3125 {
3127 use crate::tui::autocomplete::SlashCommand as AutoSlashCommand;
3128 let mut auto_commands: Vec<AutoSlashCommand> =
3129 app.extensions
3130 .iter()
3131 .flat_map(|e| e.commands())
3132 .map(|cmd| {
3133 let handler = cmd.handler;
3134 AutoSlashCommand {
3135 name: cmd.name,
3136 description: Some(cmd.description),
3137 argument_hint: None,
3138 argument_completions: None,
3139 get_argument_completions: Some(
3140 std::sync::Arc::new(
3141 move |prefix: &str| -> Vec<
3142 crate::tui::autocomplete::AutocompleteItem,
3143 > {
3144 handler
3145 .argument_completions(prefix)
3146 .into_iter()
3147 .map(|item| {
3148 crate::tui::autocomplete::AutocompleteItem {
3149 value: item.value,
3150 label: item.label,
3151 description: item.description,
3152 }
3153 })
3154 .collect()
3155 },
3156 ),
3157 ),
3158 }
3159 })
3160 .collect();
3161
3162 for skill in &app.skills {
3164 let cmd_name = format!("skill:{}", skill.name);
3165 auto_commands.push(AutoSlashCommand {
3166 name: cmd_name,
3167 description: Some(skill.description.clone()),
3168 argument_hint: None,
3169 argument_completions: None,
3170 get_argument_completions: None,
3171 });
3172 }
3173
3174 for template in &app.prompt_templates {
3176 auto_commands.push(AutoSlashCommand {
3177 name: template.name.clone(),
3178 description: Some(template.description.clone()),
3179 argument_hint: template.argument_hint.clone(),
3180 argument_completions: None,
3181 get_argument_completions: None,
3182 });
3183 }
3184 app.editor.borrow_mut().set_slash_commands(auto_commands);
3185 }
3186
3187 app.commands = app
3189 .extensions
3190 .iter()
3191 .flat_map(|e| e.commands())
3192 .map(|c| (c.name, c.description))
3193 .collect();
3194 for skill in &app.skills {
3195 app.commands
3196 .push((format!("skill:{}", skill.name), skill.description.clone()));
3197 }
3198 for template in &app.prompt_templates {
3199 app.commands
3200 .push((template.name.clone(), template.description.clone()));
3201 }
3202
3203 for ext in app.extensions.iter() {
3205 ext.on_session_start("reload");
3206 }
3207
3208 chat_info(app, format!("{} reloaded.", reload_parts.join(", ")));
3209 }
3210 CommandResult::NewSession => {
3211 app.working.stop();
3220
3221 app.status_text = None;
3223
3224 if let Some(ref mut agent_session) = app.session {
3226 agent_session.new_session();
3227 }
3228
3229 app.agent = None;
3231 app.clear_session_state();
3232
3233 if let Some(ref s) = app.session {
3235 app.footer.borrow_mut().refresh_from_session(s.session());
3236 }
3237
3238 let styled = app.theme.fg("accent", "✓ New session started");
3241 chat_add(app, std::boxed::Box::new(Text::new(styled, 1, 1, None)));
3242 }
3243 CommandResult::SessionSwitched { path } => {
3244 let new_session = crate::agent::AgentSession::open(&path, None, Some(&app.cwd));
3245 app.switch_to_session(new_session);
3246 app.status_text = Some(format!("Switched to session: {}", path.display()));
3247 }
3248 CommandResult::SessionInfo {
3249 session_id,
3250 file_path,
3251 name,
3252 message_count,
3253 user_messages,
3254 assistant_messages,
3255 tool_calls,
3256 tool_results,
3257 total_tokens,
3258 input_tokens,
3259 output_tokens,
3260 cache_read_tokens,
3261 cache_write_tokens,
3262 cost,
3263 } => {
3264 let name_display = name
3265 .or_else(|| {
3266 app.session
3267 .as_ref()
3268 .and_then(|s| s.session().session_name())
3269 })
3270 .unwrap_or_else(|| "unnamed".to_string());
3271 let file_display = file_path
3272 .as_ref()
3273 .map(|p| p.display().to_string())
3274 .unwrap_or_else(|| "in-memory".to_string());
3275 let sid = if session_id.is_empty() {
3276 app.session
3277 .as_ref()
3278 .map(|s| s.session().session_id())
3279 .unwrap_or_default()
3280 } else {
3281 session_id
3282 };
3283
3284 let total_messages = message_count;
3285
3286 let mut info = format!(
3288 "Session Info\n\n\
3289 Name: {name_display}\n\
3290 File: {file_display}\n\
3291 ID: {sid}\n\
3292 \n\
3293 Messages\n\
3294 User: {user_messages}\n\
3295 Assistant: {assistant_messages}\n\
3296 Tool Calls: {tool_calls}\n\
3297 Tool Results: {tool_results}\n\
3298 Total: {total_messages}\n\
3299 \n\
3300 Tokens\n\
3301 Input: {}\n\
3302 Output: {}",
3303 format_number(input_tokens),
3304 format_number(output_tokens),
3305 );
3306 if cache_read_tokens > 0 {
3307 info += &format!("\nCache Read: {}", format_number(cache_read_tokens));
3308 }
3309 if cache_write_tokens > 0 {
3310 info += &format!("\nCache Write: {}", format_number(cache_write_tokens));
3311 }
3312 info += &format!("\nTotal: {}", format_number(total_tokens));
3313
3314 if cost > 0.0 {
3315 info += &format!("\n\nCost\nTotal: {:.4}", cost);
3316 }
3317
3318 if let Some(ref asession) = app.session
3320 && let Some(file_path) = asession.session().session_file().as_ref()
3321 && let Some(h) = crate::agent::session::read_session_header(file_path)
3322 && let Some(ref parent) = h.parent_session
3323 {
3324 info += &format!("\n\nParent: {}", parent);
3325 }
3326
3327 chat_info(app, info.clone());
3328 }
3329 CommandResult::OpenSessionSelector => {
3330 use crate::agent::SessionRepo;
3332 let repo = crate::agent::DefaultSessionRepo::new();
3333 let sessions = repo.list_all(None);
3334
3335 if sessions.is_empty() {
3336 let msg = "No sessions found.".to_string();
3337 chat_info(app, msg.clone());
3338 } else {
3339 let mut info = format!("Available Sessions ({} total)\n\n", sessions.len());
3340 for (i, s) in sessions.iter().take(20).enumerate() {
3341 let name = s.name.as_deref().unwrap_or("unnamed");
3342 let cwd_short = s.cwd.rsplit('/').next().unwrap_or(&s.cwd);
3343 info += &format!(
3344 "{}. {} [{}] {} msgs\n {}\n\n",
3345 i + 1,
3346 name,
3347 fmt_time_short(&s.created),
3348 s.message_count,
3349 cwd_short,
3350 );
3351 }
3352 if sessions.len() > 20 {
3353 info += &format!("... and {} more sessions\n", sessions.len() - 20);
3354 }
3355 info += "Use /resume to open the interactive picker";
3356
3357 chat_info(app, info.clone());
3358 }
3359 }
3360 CommandResult::SessionNamed { name } => {
3361 if let Some(ref mut s) = app.session {
3363 s.session_mut().append_session_info(&name);
3364 }
3365
3366 let stored_name = app
3368 .session
3369 .as_ref()
3370 .and_then(|s| s.session().session_name());
3371 if let Some(ref stored) = stored_name
3372 && stored != &name
3373 {
3374 chat_info(
3375 app,
3376 format!("Session name normalized from {:?} to {:?}", name, stored),
3377 );
3378 }
3379
3380 chat_info(
3381 app,
3382 format!(
3383 "Session name set: {}",
3384 stored_name.as_deref().unwrap_or(&name)
3385 ),
3386 );
3387
3388 app.status_text = Some(format!(
3389 "Session name set: {}",
3390 stored_name.as_deref().unwrap_or(&name)
3391 ));
3392
3393 app.update_session_info();
3395 if let Some(ref s) = app.session {
3396 app.footer.borrow_mut().refresh_from_session(s.session());
3397 }
3398 }
3399 CommandResult::OpenModelSelector => {
3400 app.pending_command_result = Some(result);
3402 }
3403 CommandResult::OpenSettings => {
3404 app.pending_command_result = Some(result);
3406 }
3407 CommandResult::ScopedModels => {
3408 app.pending_command_result = Some(result);
3410 }
3411 CommandResult::ExportSession { path } => {
3412 let result = (|| -> Result<PathBuf, String> {
3414 let agent_session = app.session.as_ref().ok_or("No active session")?;
3415 let session = agent_session.session();
3416 let system_prompt = Some(app.system_prompt.as_str());
3417 let theme = crate::agent::ui::theme::current_theme();
3418 let theme_name = Some(theme.name.as_str());
3419
3420 let output_path = if path.as_ref().is_some_and(|p| p.ends_with(".jsonl")) {
3421 export::export_to_jsonl(session, &app.cwd, path.as_deref())
3422 .map_err(|e| format!("Export failed: {}", e))?
3423 } else {
3424 export::export_to_html(
3425 session,
3426 system_prompt,
3427 &app.cwd,
3428 path.as_deref(),
3429 theme_name,
3430 )
3431 .map_err(|e| format!("Export failed: {}", e))?
3432 };
3433
3434 Ok(output_path)
3435 })();
3436
3437 match result {
3438 Ok(path) => {
3439 let display = crate::builtin::shorten_path(path.to_string_lossy().as_ref());
3440 chat_info(app, format!("✓ Session exported to: {}", display));
3441 }
3442 Err(msg) => {
3443 chat_info(app, format!("✗ {}", msg));
3444 }
3445 }
3446 }
3447 result @ CommandResult::ImportSession { .. } => {
3448 app.pending_command_result = Some(result);
3450 }
3451 CommandResult::ShareSession => {
3452 let msg = "Share session - not yet implemented.".to_string();
3453 chat_info(app, msg.clone());
3454 }
3455 CommandResult::CopyLastMessage => {
3456 let text = app.session.as_ref().and_then(|s| {
3458 let entries = s.session().get_entries();
3459 entries.iter().rev().find_map(|entry| {
3460 if let SessionEntry::Message(m) = entry
3461 && matches!(
3462 &m.message,
3463 yoagent::types::AgentMessage::Llm(
3464 yoagent::types::Message::Assistant {
3465 stop_reason, ..
3466 },
3467 ) if *stop_reason != yoagent::types::StopReason::Aborted
3468 || !crate::agent::types::message_text(&m.message)
3469 .trim()
3470 .is_empty()
3471 )
3472 {
3473 let text = crate::agent::types::message_text(&m.message);
3474 let trimmed = text.trim();
3475 if !trimmed.is_empty() {
3476 return Some(trimmed.to_string());
3477 }
3478 }
3479 None
3480 })
3481 });
3482
3483 let text = match text {
3484 Some(t) => t,
3485 None => {
3486 chat_info(app, "No agent messages to copy yet.");
3487 return;
3488 }
3489 };
3490
3491 copy_to_clipboard(&text);
3493 chat_info(app, "Copied last agent message to clipboard");
3494 }
3495 CommandResult::ShowChangelog => {
3496 let msg = "Changelog - not yet implemented.".to_string();
3497 chat_info(app, msg.clone());
3498 }
3499 CommandResult::ForkSession { message_id } => {
3500 let source_path = app
3502 .session
3503 .as_ref()
3504 .and_then(|s| s.session().session_file());
3505 let session_dir = app.session.as_ref().map(|s| s.session_dir().to_path_buf());
3506 let cwd = app.cwd.clone();
3507
3508 match (source_path, session_dir) {
3509 (Some(ref source), Some(ref target_dir)) => {
3510 match crate::agent::session::fork_session(
3511 source,
3512 target_dir,
3513 message_id.as_deref(),
3514 None,
3515 ) {
3516 Ok(new_id) => {
3517 let dir_entries = std::fs::read_dir(target_dir).ok();
3519 let new_path = dir_entries.and_then(|entries| {
3520 entries
3521 .flatten()
3522 .find(|e| {
3523 let filename = e.file_name();
3524 filename.to_string_lossy().contains(&new_id)
3525 })
3526 .map(|e| e.path())
3527 });
3528
3529 match new_path {
3530 Some(ref path) => {
3531 let new_session =
3533 crate::agent::AgentSession::open(path, None, Some(&cwd));
3534 app.switch_to_session(new_session);
3535
3536 let styled = app.theme.fg(
3537 "accent",
3538 &format!("✓ Forked session: {}", path.display()),
3539 );
3540 chat_add(
3541 app,
3542 std::boxed::Box::new(Text::new(styled, 1, 1, None)),
3543 );
3544 }
3545 None => {
3546 let msg =
3547 format!("Fork created but new file not found: {}", new_id);
3548 chat_info(app, msg);
3549 }
3550 }
3551 }
3552 Err(e) => {
3553 let msg = format!("Fork failed: {}", e);
3554 chat_info(app, msg.clone());
3555 }
3556 }
3557 }
3558 _ => {
3559 let msg = "No active session to fork".to_string();
3560 chat_info(app, msg.clone());
3561 }
3562 }
3563 }
3564 CommandResult::CloneSession => {
3565 let msg = "Clone session - not yet implemented.".to_string();
3566 chat_info(app, msg.clone());
3567 }
3568 CommandResult::SessionTree => {
3569 app.pending_command_result = Some(result);
3571 }
3572 CommandResult::TrustDecision { decision } => {
3573 let msg = format!("Trust decision '{}' saved.", decision);
3574 chat_info(app, msg.clone());
3575 }
3576 CommandResult::Login {
3577 ref provider,
3578 ref api_key,
3579 } => {
3580 if let (Some(provider), Some(key)) = (provider, api_key) {
3581 handle_login(app, provider, Some(key));
3582 } else {
3583 app.pending_command_result = Some(result);
3585 }
3586 }
3587 CommandResult::Logout { ref provider } => {
3588 if let Some(p) = provider {
3589 handle_logout(app, Some(p));
3590 } else {
3591 app.pending_command_result = Some(result);
3593 }
3594 }
3595 CommandResult::CompactSession(custom_instructions) => {
3596 if app.is_streaming {
3598 interrupt_streaming(app);
3599 }
3600 app.pending_compact = Some(custom_instructions);
3601 }
3602 }
3603}
3604
3605fn find_tool_renderer(
3607 extensions: &[Box<dyn crate::agent::extension::Extension>],
3608 name: &str,
3609) -> Option<Arc<dyn ToolRenderer>> {
3610 for ext in extensions {
3611 for tool in ext.tools() {
3612 if tool.name() == name {
3613 return tool.renderer;
3614 }
3615 }
3616 }
3617 None
3618}
3619
3620fn handle_bang_command(app: &mut App, command: String) {
3624 let cwd = app.cwd.clone();
3625 let tx = app.event_tx.clone();
3626 use yoagent::types::{AgentEvent as YoEvent, Content as YoContent, ToolResult as YoResult};
3627
3628 let renderer = find_tool_renderer(&app.extensions, "bash");
3629 let mut tool = crate::agent::ui::components::ToolExecComponent::new(
3630 "bash",
3631 renderer,
3632 serde_json::json!({"command": command}),
3633 app.cwd.to_string_lossy().to_string(),
3634 "__bang__".to_string(),
3635 );
3636 tool.set_started_at(std::time::Instant::now());
3637 let (invalidate_tx, invalidate_rx) =
3638 crate::agent::ui::components::ToolExecComponent::make_invalidation_channel();
3639 app.invalidate_rxs.push(invalidate_rx);
3640 tool.set_invalidate_tx(invalidate_tx);
3641 tool.set_expanded(app.tools_expanded);
3642 let tool = Rc::new(RefCell::new(tool));
3643 app.pending_tools
3644 .insert("__bang__".to_string(), Rc::downgrade(&tool));
3645 chat_add(
3646 app,
3647 std::boxed::Box::new(crate::agent::ui::components::RcToolExec(tool)),
3648 );
3649 app.is_streaming = true;
3650 app.working.start();
3651 app.footer.borrow_mut().set_streaming(true);
3652 app.pending_tool_executions += 1;
3653
3654 let handle = tokio::spawn(async move {
3655 struct Guard<'a> {
3656 tx: &'a mpsc::UnboundedSender<yoagent::types::AgentEvent>,
3657 sent: bool,
3658 }
3659 impl Drop for Guard<'_> {
3660 fn drop(&mut self) {
3661 if !self.sent {
3662 let _ = self.tx.send(YoEvent::AgentEnd { messages: vec![] });
3663 }
3664 }
3665 }
3666 let mut guard = Guard {
3667 tx: &tx,
3668 sent: false,
3669 };
3670
3671 let send_progress = |text: &str| {
3672 let _ = tx.send(YoEvent::ProgressMessage {
3673 tool_call_id: "__bang__".to_string(),
3674 tool_name: "bash".into(),
3675 text: text.to_string(),
3676 });
3677 };
3678
3679 let mut child = match tokio::process::Command::new("sh")
3680 .arg("-c")
3681 .arg(&command)
3682 .current_dir(&cwd)
3683 .stdout(std::process::Stdio::piped())
3684 .stderr(std::process::Stdio::piped())
3685 .spawn()
3686 {
3687 Ok(c) => c,
3688 Err(e) => {
3689 let _ = tx.send(YoEvent::ToolExecutionEnd {
3690 tool_call_id: "__bang__".to_string(),
3691 tool_name: "bash".into(),
3692 result: YoResult {
3693 content: vec![YoContent::Text {
3694 text: format!("Failed to execute: {:#}", e),
3695 }],
3696 details: serde_json::Value::Null,
3697 },
3698 is_error: true,
3699 });
3700 guard.sent = true;
3701 let _ = tx.send(YoEvent::AgentEnd { messages: vec![] });
3702 return;
3703 }
3704 };
3705
3706 let mut all_output = String::new();
3707 use tokio::io::AsyncReadExt;
3709 let mut stdio = child.stdout.take().unwrap();
3710 let mut stderr = child.stderr.take().unwrap();
3711 let mut buf1 = [0u8; 4096];
3712 let mut buf2 = [0u8; 4096];
3713 let mut stdout_done = false;
3714 let mut stderr_done = false;
3715
3716 loop {
3717 tokio::select! {
3718 result = stdio.read(&mut buf1), if !stdout_done => {
3719 match result {
3720 Ok(0) => stdout_done = true,
3721 Ok(n) => {
3722 if let Ok(text) = std::str::from_utf8(&buf1[..n]) {
3723 all_output.push_str(text);
3724 send_progress(text);
3725 }
3726 }
3727 Err(_) => stdout_done = true,
3728 }
3729 }
3730 result = stderr.read(&mut buf2), if !stderr_done => {
3731 match result {
3732 Ok(0) => stderr_done = true,
3733 Ok(n) => {
3734 if let Ok(text) = std::str::from_utf8(&buf2[..n]) {
3735 all_output.push_str(text);
3736 send_progress(text);
3737 }
3738 }
3739 Err(_) => stderr_done = true,
3740 }
3741 }
3742 }
3743 if stdout_done && stderr_done {
3744 break;
3745 }
3746 }
3747
3748 let status = child.wait().await;
3750 let is_error = match &status {
3751 Ok(s) => !s.success(),
3752 Err(_) => true,
3753 };
3754 let result = if all_output.trim().is_empty() {
3755 "(no output)".to_string()
3756 } else {
3757 all_output.trim().to_string()
3758 };
3759
3760 let _ = tx.send(YoEvent::ToolExecutionEnd {
3761 tool_call_id: "__bang__".to_string(),
3762 tool_name: "bash".into(),
3763 result: YoResult {
3764 content: vec![YoContent::Text { text: result }],
3765 details: serde_json::Value::Null,
3766 },
3767 is_error,
3768 });
3769 guard.sent = true;
3770 let _ = tx.send(YoEvent::AgentEnd { messages: vec![] });
3771 });
3772 app.bash_abort_handle = Some(handle.abort_handle());
3773}
3774
3775pub fn rebuild_chat_from_messages(
3779 chat: &mut crate::tui::Container,
3780 messages: &[yoagent::types::AgentMessage],
3781 cwd: &str,
3782 hide_thinking: bool,
3783 _collapse_tool_output: bool,
3784 extensions: &[Box<dyn crate::agent::extension::Extension>],
3785) {
3786 chat.clear();
3787 use std::collections::HashMap;
3788 let mut pending_tool_components: HashMap<
3789 String,
3790 Rc<RefCell<crate::agent::ui::components::ToolExecComponent>>,
3791 > = HashMap::new();
3792
3793 for msg in messages {
3794 if crate::agent::types::message_is_user(msg) {
3795 let text = crate::agent::types::message_text(msg);
3796 if text.is_empty() {
3797 continue;
3798 }
3799 if !chat.children().is_empty() {
3800 chat.add_child(std::boxed::Box::new(Spacer::new(1)));
3801 }
3802 chat.add_child(std::boxed::Box::new(
3803 crate::agent::ui::components::UserMessageComponent::new(text),
3804 ));
3805 } else if crate::agent::types::message_is_assistant(msg) {
3806 let text = crate::agent::types::message_text(msg);
3807 if let yoagent::types::AgentMessage::Llm(yoagent::types::Message::Assistant {
3808 content,
3809 ..
3810 }) = msg
3811 {
3812 let tcs = crate::agent::types::content_tool_calls(content);
3813 if !tcs.is_empty() {
3814 if !text.trim().is_empty() {
3816 add_assistant_message(chat, &text, hide_thinking);
3817 }
3818 for (id, name, args) in &tcs {
3820 let renderer = find_tool_renderer(extensions, name);
3821 let tool = crate::agent::ui::components::ToolExecComponent::new(
3822 name,
3823 renderer,
3824 args.clone(),
3825 cwd.to_string(),
3826 id.clone(),
3827 );
3828 let tool = Rc::new(RefCell::new(tool));
3829 chat.add_child(std::boxed::Box::new(
3830 crate::agent::ui::components::RcToolExec(tool.clone()),
3831 ));
3832 pending_tool_components.insert(id.clone(), tool);
3833 }
3834 } else if !text.trim().is_empty() {
3835 add_assistant_message(chat, &text, hide_thinking);
3837 }
3838 }
3839 } else if crate::agent::types::message_is_tool_result(msg) {
3840 let is_error = crate::agent::types::message_is_error(msg);
3841 let text = crate::agent::types::message_text(msg);
3842 if let Some(tc_id) = crate::agent::types::message_tool_call_id(msg)
3843 && let Some(tool) = pending_tool_components.remove(tc_id)
3844 {
3845 let clean = text
3846 .strip_prefix("✓ ")
3847 .or_else(|| text.strip_prefix("✗ "))
3848 .unwrap_or(&text);
3849 let mut tool = tool.borrow_mut();
3850 tool.set_result_with_details(clean, is_error, None);
3851 }
3852 } else if crate::agent::types::message_is_extension(msg) {
3853 if let Some(text) = crate::agent::types::message_extension_text(msg) {
3855 if !chat.children().is_empty() {
3856 chat.add_child(std::boxed::Box::new(Spacer::new(1)));
3857 }
3858 chat.add_child(std::boxed::Box::new(InfoMessageComponent::new(text)));
3859 }
3860 }
3861 }
3862}
3863
3864pub fn chat_add(app: &mut App, component: std::boxed::Box<dyn Component>) {
3868 let mut chat = app.chat_container.borrow_mut();
3869 if !chat.children().is_empty() {
3870 chat.add_child(std::boxed::Box::new(Spacer::new(1)));
3871 }
3872 chat.add_child(component);
3873}
3874
3875pub fn chat_info(app: &mut App, msg: impl Into<String>) {
3877 chat_add(
3878 app,
3879 std::boxed::Box::new(InfoMessageComponent::new(msg.into())),
3880 );
3881}
3882
3883fn add_assistant_message(chat: &mut crate::tui::Container, text: &str, hide_thinking: bool) {
3885 if !chat.children().is_empty() {
3886 chat.add_child(std::boxed::Box::new(Spacer::new(1)));
3887 }
3888 let mut asst = crate::agent::ui::components::AssistantMessageComponent::new(text);
3889 if hide_thinking {
3890 asst.set_hide_thinking(true);
3891 }
3892 chat.add_child(std::boxed::Box::new(asst));
3893}
3894
3895fn show_summarization_prompt(app: &mut App, tui: &mut TUI, _entry_id: &str) {
3898 use crate::tui::Component;
3899 use crate::tui::keybindings::{
3900 ACTION_EDITOR_DELETE_CHAR_BACKWARD, ACTION_SELECT_CANCEL, ACTION_SELECT_CONFIRM,
3901 ACTION_SELECT_DOWN, ACTION_SELECT_UP, get_keybindings,
3902 };
3903 use crossterm::event::KeyEvent;
3904 use std::cell::RefCell;
3905 use std::rc::Rc;
3906
3907 struct SummarizationPrompt {
3908 selected_index: usize,
3909 items: [&'static str; 3],
3910 signal: Rc<RefCell<Option<OverlayResult>>>,
3911 entry_id: String,
3912 edit_mode: bool,
3913 edit_text: String,
3914 }
3915
3916 impl Component for SummarizationPrompt {
3917 fn render(&mut self, width: usize) -> Vec<String> {
3918 let theme = crate::agent::ui::theme::current_theme();
3919 let mut lines = Vec::new();
3920
3921 lines.push(theme.fg("muted", &"─".repeat(width.saturating_sub(2))));
3922 lines.push(String::new());
3923 lines.push(format!(" {}", theme.bold("Summarize branch?")));
3924 lines.push(String::new());
3925
3926 if self.edit_mode {
3927 lines.push(format!(
3929 " {}",
3930 theme.fg("muted", "Custom summarization instructions (Enter to submit, Shift/Ctrl+Enter for newline):")
3931 ));
3932 lines.push(String::new());
3933 if self.edit_text.is_empty() {
3935 lines.push(format!(
3936 " {}",
3937 theme.fg("muted", "<type here, Enter for newline>")
3938 ));
3939 } else {
3940 for line in self.edit_text.lines() {
3941 lines.push(format!(" {}", line));
3942 }
3943 }
3944 lines.push(String::new());
3945 lines.push(format!(
3946 " {}",
3947 theme.fg(
3948 "muted",
3949 "Enter: submit \u{00b7} Shift/Ctrl+Enter: newline \u{00b7} Esc: back"
3950 )
3951 ));
3952 } else {
3953 for (i, item) in self.items.iter().enumerate() {
3954 let prefix = if i == self.selected_index {
3955 theme.fg("accent", "\u{203a} ")
3956 } else {
3957 " ".to_string()
3958 };
3959 let text = if i == self.selected_index {
3960 theme.fg("accent", item)
3961 } else {
3962 theme.text_color(item)
3963 };
3964 lines.push(format!("{}{}", prefix, text));
3965 }
3966 lines.push(String::new());
3967 lines.push(theme.fg(
3968 "muted",
3969 " \u{2191}/\u{2193} navigate \u{00b7} Enter select \u{00b7} Esc back to tree",
3970 ));
3971 }
3972
3973 lines
3974 }
3975
3976 fn handle_input(&mut self, key: &KeyEvent) -> bool {
3977 let kb = get_keybindings();
3978
3979 if self.edit_mode {
3980 if key.code == crossterm::event::KeyCode::Esc {
3981 self.edit_mode = false;
3982 return true;
3983 }
3984 if key.code == crossterm::event::KeyCode::Enter
3986 && !key
3987 .modifiers
3988 .contains(crossterm::event::KeyModifiers::SHIFT)
3989 && !key.modifiers.contains(crossterm::event::KeyModifiers::ALT)
3990 && !key
3991 .modifiers
3992 .contains(crossterm::event::KeyModifiers::CONTROL)
3993 {
3994 let instructions = self.edit_text.trim().to_string();
3995 let ci = if instructions.is_empty() {
3996 None
3997 } else {
3998 Some(instructions)
3999 };
4000 *self.signal.borrow_mut() = Some(OverlayResult::TreeSummarizeChoice {
4001 entry_id: self.entry_id.clone(),
4002 summarize: true,
4003 custom_instructions: ci,
4004 });
4005 return true;
4006 }
4007 if (key.code == crossterm::event::KeyCode::Enter
4009 && (key
4010 .modifiers
4011 .contains(crossterm::event::KeyModifiers::SHIFT)
4012 || key
4013 .modifiers
4014 .contains(crossterm::event::KeyModifiers::CONTROL)))
4015 || (key.code == crossterm::event::KeyCode::Char('j')
4016 && key
4017 .modifiers
4018 .contains(crossterm::event::KeyModifiers::CONTROL))
4019 {
4020 self.edit_text.push('\n');
4021 return true;
4022 }
4023 if kb.matches(key, ACTION_EDITOR_DELETE_CHAR_BACKWARD) {
4024 self.edit_text.pop();
4025 return true;
4026 }
4027 if let crossterm::event::KeyCode::Char(c) = key.code
4028 && !c.is_control()
4029 {
4030 self.edit_text.push(c);
4031 return true;
4032 }
4033 return true;
4034 }
4035
4036 if kb.matches(key, ACTION_SELECT_UP) {
4037 self.selected_index = if self.selected_index == 0 {
4038 self.items.len() - 1
4039 } else {
4040 self.selected_index - 1
4041 };
4042 return true;
4043 }
4044
4045 if kb.matches(key, ACTION_SELECT_DOWN) {
4046 self.selected_index = if self.selected_index >= self.items.len() - 1 {
4047 0
4048 } else {
4049 self.selected_index + 1
4050 };
4051 return true;
4052 }
4053
4054 if kb.matches(key, ACTION_SELECT_CONFIRM) {
4055 match self.selected_index {
4056 0 => {
4057 *self.signal.borrow_mut() = Some(OverlayResult::TreeSummarizeChoice {
4058 entry_id: self.entry_id.clone(),
4059 summarize: false,
4060 custom_instructions: None,
4061 });
4062 }
4063 1 => {
4064 *self.signal.borrow_mut() = Some(OverlayResult::TreeSummarizeChoice {
4065 entry_id: self.entry_id.clone(),
4066 summarize: true,
4067 custom_instructions: None,
4068 });
4069 }
4070 2 => {
4071 self.edit_mode = true;
4072 self.edit_text.clear();
4073 return true;
4074 }
4075 _ => {}
4076 }
4077 return true;
4078 }
4079
4080 if kb.matches(key, ACTION_SELECT_CANCEL) {
4081 *self.signal.borrow_mut() = Some(OverlayResult::TreeReopen(self.entry_id.clone()));
4082 return true;
4083 }
4084
4085 false
4086 }
4087
4088 fn invalidate(&mut self) {}
4089 }
4090
4091 let entry_id = _entry_id.to_string();
4092 let prompt = SummarizationPrompt {
4093 selected_index: 0,
4094 items: ["No summary", "Summarize", "Summarize with custom prompt"],
4095 signal: app.overlay_result_signal.clone(),
4096 entry_id,
4097 edit_mode: false,
4098 edit_text: String::new(),
4099 };
4100
4101 tui.show_top_overlay(Box::new(prompt));
4102}
4103
4104fn show_status(app: &mut App, message: String) {
4111 let mut chat = app.chat_container.borrow_mut();
4112 if let Some(prev_len) = app.last_status_len
4114 && chat.len() == prev_len
4115 && prev_len >= 2
4116 {
4117 chat.pop_child(); chat.pop_child(); }
4120 app.last_status_len = None;
4121 drop(chat);
4122
4123 let mut chat = app.chat_container.borrow_mut();
4125 if !chat.children().is_empty() {
4126 chat.add_child(std::boxed::Box::new(Spacer::new(1)));
4127 }
4128 chat.add_child(std::boxed::Box::new(InfoMessageComponent::new(message)));
4129 app.last_status_len = Some(chat.len());
4130}
4131
4132fn extract_text_content(content: &[yoagent::types::Content]) -> String {
4134 content
4135 .iter()
4136 .filter_map(|c| {
4137 if let yoagent::types::Content::Text { text } = c {
4138 Some(text.clone())
4139 } else {
4140 None
4141 }
4142 })
4143 .collect::<Vec<_>>()
4144 .join("")
4145}
4146
4147fn copy_to_clipboard(text: &str) -> bool {
4152 use std::io::Write;
4153 let mut copied = false;
4154
4155 if !copied
4157 && std::process::Command::new("pbcopy")
4158 .stdin(std::process::Stdio::piped())
4159 .stdout(std::process::Stdio::null())
4160 .stderr(std::process::Stdio::null())
4161 .spawn()
4162 .ok()
4163 .and_then(|mut child| {
4164 let _ = child.stdin.take().map(|mut stdin| {
4165 let _ = stdin.write_all(text.as_bytes());
4166 });
4167 child.wait().ok()
4168 })
4169 .is_some_and(|s| s.success())
4170 {
4171 copied = true;
4172 }
4173
4174 if !copied
4176 && std::process::Command::new("clip")
4177 .stdin(std::process::Stdio::piped())
4178 .stdout(std::process::Stdio::null())
4179 .stderr(std::process::Stdio::null())
4180 .spawn()
4181 .ok()
4182 .and_then(|mut child| {
4183 let _ = child.stdin.take().map(|mut stdin| {
4184 let _ = stdin.write_all(text.as_bytes());
4185 });
4186 child.wait().ok()
4187 })
4188 .is_some_and(|s| s.success())
4189 {
4190 copied = true;
4191 }
4192
4193 if !copied
4195 && std::env::var("TERMUX_VERSION").is_ok()
4196 && let Ok(mut child) = std::process::Command::new("termux-clipboard-set")
4197 .stdin(std::process::Stdio::piped())
4198 .stdout(std::process::Stdio::null())
4199 .stderr(std::process::Stdio::null())
4200 .spawn()
4201 {
4202 let _ = child.stdin.take().map(|mut stdin| {
4203 let _ = stdin.write_all(text.as_bytes());
4204 });
4205 copied = child.wait().ok().is_some_and(|s| s.success());
4206 }
4207
4208 if !copied
4210 && std::env::var("WAYLAND_DISPLAY").is_ok()
4211 && std::process::Command::new("which")
4212 .arg("wl-copy")
4213 .stdout(std::process::Stdio::null())
4214 .stderr(std::process::Stdio::null())
4215 .status()
4216 .ok()
4217 .is_some_and(|s| s.success())
4218 && let Ok(mut child) = std::process::Command::new("wl-copy")
4219 .stdin(std::process::Stdio::piped())
4220 .stdout(std::process::Stdio::null())
4221 .stderr(std::process::Stdio::null())
4222 .spawn()
4223 {
4224 let _ = child.stdin.take().map(|mut stdin| {
4225 let _ = stdin.write_all(text.as_bytes());
4226 });
4227 copied = true;
4229 }
4230
4231 if !copied
4233 && std::process::Command::new("xclip")
4234 .arg("-selection")
4235 .arg("clipboard")
4236 .arg("-i")
4237 .stdin(std::process::Stdio::piped())
4238 .stdout(std::process::Stdio::null())
4239 .stderr(std::process::Stdio::null())
4240 .spawn()
4241 .ok()
4242 .and_then(|mut child| {
4243 let _ = child.stdin.take().map(|mut stdin| {
4244 let _ = stdin.write_all(text.as_bytes());
4245 });
4246 child.wait().ok()
4247 })
4248 .is_some_and(|s| s.success())
4249 {
4250 copied = true;
4251 }
4252
4253 if !copied
4254 && std::process::Command::new("xsel")
4255 .arg("--clipboard")
4256 .arg("--input")
4257 .stdin(std::process::Stdio::piped())
4258 .stdout(std::process::Stdio::null())
4259 .stderr(std::process::Stdio::null())
4260 .spawn()
4261 .ok()
4262 .and_then(|mut child| {
4263 let _ = child.stdin.take().map(|mut stdin| {
4264 let _ = stdin.write_all(text.as_bytes());
4265 });
4266 child.wait().ok()
4267 })
4268 .is_some_and(|s| s.success())
4269 {
4270 copied = true;
4271 }
4272
4273 let remote = std::env::var("SSH_CONNECTION").is_ok()
4275 || std::env::var("SSH_CLIENT").is_ok()
4276 || std::env::var("MOSH_CONNECTION").is_ok();
4277
4278 if remote || !copied {
4279 use base64::Engine as _;
4280 let encoded = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
4281 if encoded.len() <= 100_000 {
4283 let _ = writeln!(std::io::stdout(), "\x1b]52;c;{}\x07", encoded);
4284 let _ = std::io::stdout().flush();
4285 copied = true;
4286 }
4287 }
4288
4289 copied
4290}
4291
4292fn handle_agent_event(app: &mut App, event: yoagent::types::AgentEvent) {
4299 {
4302 let ev = &event;
4303 if let E::MessageEnd { message } = ev {
4304 if crate::agent::types::message_is_user(message)
4305 && let Some(ref mut s) = app.session
4306 {
4307 s.reset_overflow_recovery();
4308 }
4309 if crate::agent::types::message_error(message).is_none()
4310 && !crate::agent::types::message_is_system_stop(message)
4311 && let Some(ref mut s) = app.session
4312 {
4313 s.on_agent_event(ev);
4314 }
4315 }
4316 if let E::ToolExecutionEnd { tool_call_id, .. } = ev
4317 && tool_call_id != "__bang__"
4318 && let Some(ref mut s) = app.session
4319 {
4320 s.on_agent_event(ev);
4321 }
4322 if let E::AgentEnd { .. } = ev
4323 && let Some(ref mut s) = app.session
4324 {
4325 s.on_agent_event(ev);
4326 }
4327 }
4328
4329 use yoagent::types::AgentEvent as E;
4331 match event {
4332 E::AgentStart => {
4333 app.is_streaming = true;
4334 app.working.start();
4335 app.refresh_git_branch();
4336 }
4337 E::TurnStart => {}
4338 E::MessageStart { message } => {
4339 if crate::agent::types::message_is_user(&message) {
4343 let text = crate::agent::types::message_text(&message);
4344 if !text.is_empty() {
4345 chat_add(
4346 app,
4347 std::boxed::Box::new(
4348 crate::agent::ui::components::UserMessageComponent::new(&text),
4349 ),
4350 );
4351 }
4352 }
4353 }
4354 E::MessageUpdate { delta, .. } => {
4355 use yoagent::types::StreamDelta;
4356 match delta {
4357 StreamDelta::Text { delta } => {
4358 if let Some(weak) = app.streaming_component.as_ref().and_then(|w| w.upgrade()) {
4359 weak.borrow_mut().append_text(&delta);
4360 } else {
4361 use crate::tui::components::rc_ref_cell_component::RcRefCellComponent;
4362 let comp = Rc::new(RefCell::new(
4363 crate::agent::ui::components::AssistantMessageComponent::new(&delta),
4364 ));
4365 if app.hide_thinking {
4366 comp.borrow_mut().set_hide_thinking(true);
4367 }
4368 app.streaming_component = Some(Rc::downgrade(&comp));
4369 app.chat_container
4370 .borrow_mut()
4371 .add_child(std::boxed::Box::new(RcRefCellComponent(comp)));
4372 }
4373 }
4374 StreamDelta::Thinking { delta } => {
4375 if let Some(weak) = app.streaming_component.as_ref().and_then(|w| w.upgrade()) {
4376 weak.borrow_mut()
4377 .add_thinking(&delta, app.thinking_level.clone());
4378 } else {
4379 use crate::tui::components::rc_ref_cell_component::RcRefCellComponent;
4380 let mut comp =
4381 crate::agent::ui::components::AssistantMessageComponent::new("");
4382 comp.add_thinking(&delta, app.thinking_level.clone());
4383 if app.hide_thinking {
4384 comp.set_hide_thinking(true);
4385 }
4386 let comp = Rc::new(RefCell::new(comp));
4387 app.streaming_component = Some(Rc::downgrade(&comp));
4388 app.chat_container
4389 .borrow_mut()
4390 .add_child(std::boxed::Box::new(RcRefCellComponent(comp)));
4391 }
4392 }
4393 StreamDelta::ToolCallDelta { .. } => {}
4394 }
4395 }
4396 E::ToolExecutionStart {
4397 tool_call_id,
4398 tool_name,
4399 args,
4400 } => {
4401 app.pending_tool_executions += 1;
4402 app.streaming_component = None;
4403 let name = tool_name;
4404 let renderer = find_tool_renderer(&app.extensions, &name);
4405 let started_at = std::time::Instant::now();
4406 let (invalidate_tx, invalidate_rx) =
4407 crate::agent::ui::components::ToolExecComponent::make_invalidation_channel();
4408 app.invalidate_rxs.push(invalidate_rx);
4409 let comp: Rc<RefCell<_>> = {
4410 let mut tool = crate::agent::ui::components::ToolExecComponent::new(
4411 &name,
4412 renderer,
4413 args.clone(),
4414 app.cwd.to_string_lossy().to_string(),
4415 tool_call_id.clone(),
4416 );
4417 tool.set_started_at(std::time::Instant::now());
4418 tool.set_invalidate_tx(invalidate_tx);
4419 Rc::new(RefCell::new(tool))
4420 };
4421 comp.borrow_mut().set_expanded(app.tools_expanded);
4422 app.pending_tools
4423 .insert(tool_call_id.clone(), Rc::downgrade(&comp));
4424 app.tool_call_start_times
4425 .insert(tool_call_id.clone(), started_at);
4426 chat_add(
4427 app,
4428 std::boxed::Box::new(crate::agent::ui::components::RcToolExec(comp)),
4429 );
4430 }
4431 E::ToolExecutionUpdate {
4432 tool_call_id,
4433 partial_result,
4434 ..
4435 } => {
4436 let partial_text = extract_text_content(&partial_result.content);
4438 if !partial_text.is_empty()
4439 && let Some(weak) = app.pending_tools.get(&tool_call_id)
4440 && let Some(comp) = weak.upgrade()
4441 {
4442 comp.borrow_mut().append_output(&partial_text);
4443 }
4444 }
4445 E::ToolExecutionEnd {
4446 tool_call_id,
4447 tool_name: _,
4448 result,
4449 is_error,
4450 } => {
4451 app.pending_tool_executions = app.pending_tool_executions.saturating_sub(1);
4452 let content = extract_text_content(&result.content);
4453 if let Some(weak) = app.pending_tools.get(&tool_call_id)
4454 && let Some(comp) = weak.upgrade()
4455 {
4456 comp.borrow_mut()
4457 .set_result_with_details(&content, is_error, Some(result.details));
4458 app.tool_call_start_times.remove(&tool_call_id);
4459 }
4460 }
4461 E::ProgressMessage {
4462 text, tool_name, ..
4463 } => {
4464 if let Some(weak) = app.pending_tools.get("__bang__")
4466 && let Some(comp) = weak.upgrade()
4467 {
4468 comp.borrow_mut().append_output(&text);
4469 } else if tool_name.is_empty() {
4470 app.status_text = Some(text.trim().to_string());
4472 }
4473 }
4474 E::TurnEnd { message, .. } => {
4475 app.streaming_component = None;
4476 if let Some(err) = crate::agent::types::message_error(&message) {
4478 chat_info(app, format!("Provider error: {}", err));
4479 }
4480 }
4481 E::AgentEnd { messages } => {
4482 app.streaming_component = None;
4483 app.is_streaming = false;
4484 app.working.stop();
4485 app.footer.borrow_mut().set_streaming(false);
4486 if let Some(ref s) = app.session {
4488 app.footer.borrow_mut().refresh_from_session(s.session());
4489 }
4490 app.pending_auto_compact = app.auto_compact;
4493 for msg in messages.iter().rev() {
4499 if let Some(yoagent::types::Message::Assistant {
4500 content,
4501 stop_reason,
4502 error_message,
4503 ..
4504 }) = msg.as_llm()
4505 && stop_reason != &yoagent::types::StopReason::ToolUse
4506 {
4507 if let Some(err) = error_message {
4508 chat_info(app, format!("Provider error: {}", err));
4509 break;
4510 }
4511 let has_visible = content.iter().any(|c| match c {
4515 yoagent::types::Content::Text { text } => !text.trim().is_empty(),
4516 yoagent::types::Content::ToolCall { .. } => true,
4517 _ => false,
4518 });
4519 if !has_visible {
4520 chat_info(
4521 app,
4522 "The agent returned an empty response. \
4523 This can happen when the provider's context \
4524 limit is exceeded or the model declined to \
4525 respond. Try sending a new message."
4526 .to_string(),
4527 );
4528 break;
4529 }
4530 }
4531 }
4532 }
4533 E::MessageEnd { message } => {
4534 if let Some(err) = crate::agent::types::message_error(&message) {
4537 chat_info(app, err.to_string());
4538 let ext = crate::agent::types::extension_message("error", err, true);
4539 if let Some(ref mut s) = app.session {
4540 s.persist_extension_message(&ext);
4541 }
4542 } else if crate::agent::types::message_is_system_stop(&message) {
4543 let text = crate::agent::types::message_text(&message);
4544 chat_info(app, text.clone());
4545 if let Some(ref mut s) = app.session {
4546 let ext = crate::agent::types::extension_message("system_stop", text, true);
4547 s.persist_extension_message(&ext);
4548 }
4549 } else if crate::agent::types::message_is_extension(&message) {
4550 if let Some(text) = crate::agent::types::message_extension_text(&message) {
4552 chat_info(app, text);
4553 }
4554 }
4555 }
4556 E::InputRejected { reason } => {
4557 let msg = format!("Input rejected: {}", reason);
4558 chat_info(app, msg);
4559 }
4560 }
4561}
4562
4563fn parse_bang_command(input: &str) -> Option<(String, bool)> {
4565 if let Some(rest) = input.strip_prefix("!!") {
4566 let cmd = rest.trim();
4567 if cmd.is_empty() {
4568 None
4569 } else {
4570 Some((cmd.to_string(), true))
4571 }
4572 } else if let Some(rest) = input.strip_prefix('!') {
4573 let cmd = rest.trim();
4574 if cmd.is_empty() {
4575 None
4576 } else {
4577 Some((cmd.to_string(), false))
4578 }
4579 } else {
4580 None
4581 }
4582}
4583
4584fn format_number(n: u64) -> String {
4586 let s = n.to_string();
4587 let mut result = String::new();
4588 for (i, c) in s.chars().rev().enumerate() {
4589 if i > 0 && i % 3 == 0 {
4590 result.push(',');
4591 }
4592 result.push(c);
4593 }
4594 result.chars().rev().collect()
4595}
4596
4597fn fmt_time_short(dt: &chrono::DateTime<chrono::Utc>) -> String {
4599 dt.format("%Y-%m-%d %H:%M").to_string()
4600}
4601
4602fn xml_escape(s: &str) -> String {
4605 s.replace('&', "&")
4606 .replace('<', "<")
4607 .replace('>', ">")
4608 .replace('"', """)
4609 .replace('\'', "'")
4610}
4611
4612fn strip_frontmatter(content: &str) -> String {
4613 let content = content.trim_start();
4614 if !content.starts_with("---") {
4615 return content.to_string();
4616 }
4617 let remaining = &content[3..];
4618 let end = match remaining.find("---") {
4619 Some(pos) => pos,
4620 None => return content.to_string(),
4621 };
4622 let body_start = 3 + end + 3;
4623 content[body_start..].trim().to_string()
4624}
4625
4626fn read_skill_body(file_path: &std::path::Path) -> Option<String> {
4627 let content = std::fs::read_to_string(file_path).ok()?;
4628 Some(strip_frontmatter(&content))
4629}
4630
4631fn format_skill_invocation(skill: &yoagent::skills::Skill, extra: Option<&str>) -> Option<String> {
4632 let body = read_skill_body(&skill.file_path)?;
4633 let block = format!(
4634 r#"<skill name="{}" location="{}">
4635References are relative to {}.
4636
4637{}
4638</skill>"#,
4639 xml_escape(&skill.name),
4640 xml_escape(&skill.file_path.to_string_lossy()),
4641 xml_escape(&skill.base_dir.to_string_lossy()),
4642 body
4643 );
4644 Some(match extra {
4645 Some(instr) if !instr.is_empty() => format!("{}\n\n{}", block, instr),
4646 _ => block,
4647 })
4648}
4649
4650fn expand_skill_command(text: &str, skills: &[yoagent::skills::Skill]) -> String {
4651 if !text.starts_with("/skill:") {
4652 return text.to_string();
4653 }
4654 let rest = &text[7..];
4655 let (skill_name, args) = match rest.find(' ') {
4656 Some(pos) => (&rest[..pos], rest[pos + 1..].trim()),
4657 None => (rest, ""),
4658 };
4659 match skills.iter().find(|s| s.name == skill_name) {
4660 Some(s) => format_skill_invocation(s, if args.is_empty() { None } else { Some(args) })
4661 .unwrap_or_else(|| text.to_string()),
4662 None => text.to_string(),
4663 }
4664}
4665
4666pub fn parse_skill_block(text: &str) -> Option<(&str, &str, Option<&str>)> {
4669 let text = text.trim();
4670 let after_open = text.strip_prefix("<skill name=\"")?;
4671 let (name, rest) = after_open.split_once("\" location=\"")?;
4672 let (_location, rest) = rest.split_once("\">\n")?;
4673 let close_tag = "\n</skill>";
4675 let content_end = rest.rfind(close_tag)?;
4676 let body = rest[..content_end].trim();
4677 let after_close = rest[content_end + close_tag.len()..].trim();
4678 let user_message = if after_close.is_empty() {
4679 None
4680 } else {
4681 Some(after_close)
4682 };
4683 Some((name, body, user_message))
4684}
4685
4686pub fn format_skill_block_for_display(text: &str) -> Option<String> {
4689 let (name, body, user_message) = parse_skill_block(text)?;
4690 let mut result = String::new();
4691 result.push_str("**[");
4693 result.push_str("skill] ");
4694 result.push_str(name);
4695 result.push_str("**\n\n");
4696 result.push_str(body);
4698 result.push('\n');
4699 if let Some(msg) = user_message {
4701 result.push_str("\n---\n");
4702 result.push_str(msg);
4703 result.push('\n');
4704 }
4705 Some(result)
4706}