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::provider;
18use crate::provider::ProviderRegistry;
19
20use crate::agent::ui::chat_editor::{ChatEditor, InputAction};
21
22use crate::agent::ui::components::EditorComponent;
23use crate::agent::ui::components::FooterComponent;
24use crate::agent::ui::components::InfoMessageComponent;
25use crate::agent::ui::footer::Footer;
26use crate::agent::ui::theme::RabTheme;
27use crate::agent::ui::working::WorkingIndicator;
28use crate::builtin::commands::SessionInfoInternal;
29use crate::tui::Component;
30use crate::tui::TUI;
31use crate::tui::focusable::Focusable;
32
33#[derive(Debug, Clone)]
35pub enum OverlayResult {
36 ModelSelected(String),
38 ScopedModelsAccepted(Option<Vec<String>>),
40 ScopedModelsCancelled,
42 LoginProviderSelected(String),
44 LoginApiKeyProvided { provider: String, key: String },
46 LoginAuthTypeSelected(AuthType),
48 LogoutProviderSelected(String),
50}
51
52use crate::agent::ui::components::oauth_selector::AuthType;
53use crate::agent::ui::theme::ThemeKey;
54use crate::tui::components::Spacer;
55use crate::tui::components::Text;
56use crate::tui::terminal::{self, ProcessTerminal, TerminalTrait};
57use crossterm::event::KeyEvent;
58use tokio::sync::mpsc;
59
60const ALL_THINKING_LEVELS: &[&str] = &["xhigh", "high", "medium", "low", "off"];
64
65fn available_thinking_levels(app: &App) -> Vec<&'static str> {
68 let thinking_map: Option<std::collections::HashMap<String, Option<serde_json::Value>>> = app
70 .registry
71 .resolve(&app.model, Some(&app.current_provider))
72 .ok()
73 .and_then(|r| {
74 r.model_config
75 .headers
76 .get("_rab_thinking_map")
77 .and_then(|json| serde_json::from_str(json).ok())
78 });
79
80 match thinking_map {
81 Some(map) => ALL_THINKING_LEVELS
82 .iter()
83 .filter(|level| {
84 if **level == "off" {
85 return true; }
87 !matches!(map.get(**level), Some(None))
89 })
90 .copied()
91 .collect(),
92 None => ALL_THINKING_LEVELS.to_vec(),
93 }
94}
95
96pub struct AppConfig {
98 pub model: String,
99 pub provider: String,
100 pub system_prompt: String,
101 pub extensions: Vec<Box<dyn Extension>>,
102 pub cwd: PathBuf,
103 pub thinking_level: Option<String>,
104 pub available_models: Vec<String>,
105 pub hide_thinking: bool,
106 pub collapse_tool_output: bool,
107 pub interactive: bool,
108 pub settings: crate::agent::settings::Settings,
109 pub context_files: Vec<String>,
111
112 pub skills: Vec<yoagent::skills::Skill>,
114 pub session_info: Option<std::sync::Arc<std::sync::Mutex<Option<SessionInfoInternal>>>>,
116 pub api_key: String,
118 pub registry: Arc<ProviderRegistry>,
120}
121
122pub struct App {
124 cwd: PathBuf,
125 model: String,
126 current_provider: String,
127 thinking_level: Option<String>,
128 system_prompt: String,
129 theme: RabTheme,
130
131 commands: Vec<(String, String)>,
133
134 available_models: Vec<String>,
136 registry: Arc<ProviderRegistry>,
138
139 pub chat_container: std::rc::Rc<std::cell::RefCell<crate::tui::Container>>,
142
143 pub status_section: std::rc::Rc<std::cell::RefCell<crate::tui::components::DynamicLines>>,
146 pub working_section: std::rc::Rc<std::cell::RefCell<crate::tui::components::DynamicLines>>,
148
149 editor: Rc<RefCell<ChatEditor>>,
151
152 event_tx: mpsc::UnboundedSender<yoagent::types::AgentEvent>,
154 event_rx: mpsc::UnboundedReceiver<yoagent::types::AgentEvent>,
155
156 is_streaming: bool,
158 pending_submit: Option<String>,
160 pending_compact: Option<Option<String>>,
162 pending_auto_compact: bool,
164 agent: Option<yoagent::agent::Agent>,
166 forward_handle: Option<tokio::task::JoinHandle<()>>,
169
170 oauth_join_handle: Option<tokio::task::JoinHandle<()>>,
172
173 pending_oauth_provider: Option<String>,
176
177 hide_thinking: bool,
179 collapse_tool_output: bool,
180 tools_expanded: bool,
182
183 scroll_offset: usize,
185
186 last_clear_time: std::time::Instant,
188
189 should_quit: bool,
191
192 pending_tool_executions: usize,
197
198 bash_abort_handle: Option<tokio::task::AbortHandle>,
200
201 session: Option<AgentSession>,
203
204 footer: Rc<RefCell<Footer>>,
206
207 footer_provider: Rc<RefCell<FooterDataProvider>>,
209
210 pending_tools: HashMap<String, Weak<RefCell<crate::agent::ui::components::ToolExecComponent>>>,
213
214 tool_call_start_times: HashMap<String, std::time::Instant>,
217
218 invalidate_rxs: Vec<tokio::sync::mpsc::UnboundedReceiver<()>>,
221
222 streaming_component:
225 Option<Weak<RefCell<crate::agent::ui::components::AssistantMessageComponent>>>,
226
227 working: WorkingIndicator,
229
230 status_text: Option<String>,
232
233 pending_command_result: Option<CommandResult>,
236
237 overlay_result_signal: Rc<RefCell<Option<OverlayResult>>>,
239
240 pending_scoped_ids: Rc<RefCell<Option<Vec<String>>>>,
242
243 extensions: Arc<Vec<Box<dyn Extension>>>,
246 skills: Vec<yoagent::skills::Skill>,
248 api_key: String,
250 session_info: Option<std::sync::Arc<std::sync::Mutex<Option<SessionInfoInternal>>>>,
252
253 auto_compact: bool,
255
256 settings: crate::agent::settings::Settings,
258
259 header: Rc<RefCell<crate::agent::ui::components::HeaderComponent>>,
264
265 scoped_model_ids: Option<Vec<String>>,
267
268 session_picker: Option<crate::agent::ui::components::SessionPicker>,
270
271 last_status_len: Option<usize>,
276 }
279
280impl App {
281 fn new(config: AppConfig, session: AgentSession) -> Self {
282 let mut agent_session = session;
283 let model_config = config
284 .registry
285 .resolve(&config.model, Some(&config.provider))
286 .ok()
287 .map(|r| r.model_config.clone())
288 .unwrap_or_else(|| {
289 let mut mc = crate::agent::base_model_config(&config.model);
290 mc.context_window =
291 crate::agent::compaction::get_model_context_window(&config.model) as u32;
292 mc
293 });
294 agent_session.set_compaction_config(
295 config.api_key.clone(),
296 &config.model,
297 crate::agent::compaction::get_model_context_window(&config.model),
298 Some(model_config),
299 );
300 agent_session.set_registry(config.registry.clone());
301 agent_session.set_auto_compact(config.settings.auto_compact.unwrap_or(true));
302 let (tx, rx) = mpsc::unbounded_channel();
303 use crate::agent::ui::theme::current_theme;
304 let theme = current_theme().clone();
305
306 let mut editor = ChatEditor::new(&theme, config.cwd.clone());
307
308 use crate::tui::autocomplete::AutocompleteItem as AutoAutocompleteItem;
310 use crate::tui::autocomplete::SlashCommand as AutoSlashCommand;
311 let auto_commands: Vec<AutoSlashCommand> = config
312 .extensions
313 .iter()
314 .flat_map(|e| e.commands())
315 .map(|cmd| {
316 let handler = cmd.handler;
317 AutoSlashCommand {
318 name: cmd.name,
319 description: Some(cmd.description),
320 argument_hint: None,
321 argument_completions: None,
322 get_argument_completions: Some(std::sync::Arc::new(
323 move |prefix: &str| -> Vec<AutoAutocompleteItem> {
324 handler
325 .argument_completions(prefix)
326 .into_iter()
327 .map(|item| AutoAutocompleteItem {
328 value: item.value,
329 label: item.label,
330 description: item.description,
331 })
332 .collect()
333 },
334 )),
335 }
336 })
337 .collect();
338 editor.set_slash_commands(auto_commands);
339
340 let commands: Vec<(String, String)> = config
342 .extensions
343 .iter()
344 .flat_map(|e| e.commands())
345 .map(|c| (c.name, c.description))
346 .collect();
347
348 let editor = Rc::new(RefCell::new(editor));
349
350 let footer_provider = Rc::new(RefCell::new(FooterDataProvider::new(config.cwd.clone())));
351
352 let mut footer = Footer::new(
353 config.cwd.to_string_lossy().to_string(),
354 footer_provider.clone(),
355 );
356 footer.set_context_window(crate::agent::compaction::get_model_context_window(
357 &config.model,
358 ));
359
360 footer_provider
362 .borrow_mut()
363 .set_available_provider_count(config.registry.count_providers());
364
365 {
368 let has_model_entry = !agent_session
369 .session()
370 .find_entries("model_change")
371 .is_empty();
372 if !has_model_entry {
373 agent_session.on_model_change(&config.provider, &config.model);
374 }
375 let has_thinking_entry = !agent_session
376 .session()
377 .find_entries("thinking_level_change")
378 .is_empty();
379 if !has_thinking_entry && let Some(ref level) = config.thinking_level {
380 agent_session.on_thinking_level_change(level);
381 }
382 }
383
384 let footer = Rc::new(RefCell::new(footer));
385
386 let context = agent_session.session().build_session_context();
388 let history_messages = context.messages.clone();
389
390 let mut resource_parts: Vec<String> = Vec::new();
392 if !config.context_files.is_empty() {
393 let ctx = config.context_files.join(", ");
394 resource_parts.push(format!("Context: {}", ctx));
395 }
396 if !config.skills.is_empty() {
397 let skill_names: Vec<&str> = config.skills.iter().map(|s| s.name.as_str()).collect();
398 resource_parts.push(format!("Skills: {}", skill_names.join(", ")));
399 }
400
401 let cwd_string = config.cwd.to_string_lossy().to_string();
405 let chat_container =
406 std::rc::Rc::new(std::cell::RefCell::new(crate::tui::Container::new()));
407 {
408 let mut chat = chat_container.borrow_mut();
409
410 if !resource_parts.is_empty() {
412 chat.add_child(std::boxed::Box::new(
413 crate::agent::ui::components::InfoMessageComponent::new(
414 resource_parts.join(" · "),
415 ),
416 ));
417 }
418
419 rebuild_chat_from_messages(
420 &mut chat,
421 &history_messages,
422 &cwd_string,
423 config.hide_thinking,
424 config.collapse_tool_output,
425 &config.extensions,
426 );
427 }
428
429 let mut result = Self {
430 cwd: config.cwd,
431 model: config.model,
432 current_provider: config.provider,
433 thinking_level: config.thinking_level,
434 system_prompt: config.system_prompt,
435 theme,
436 commands,
437 available_models: config.available_models,
438 registry: config.registry.clone(),
439 chat_container,
440 pending_tools: HashMap::new(),
441 tool_call_start_times: HashMap::new(),
442 invalidate_rxs: Vec::new(),
443 streaming_component: None,
444
445 status_section: std::rc::Rc::new(std::cell::RefCell::new(
446 crate::tui::components::DynamicLines::new(),
447 )),
448 working_section: std::rc::Rc::new(std::cell::RefCell::new(
449 crate::tui::components::DynamicLines::new(),
450 )),
451 editor,
452 event_tx: tx,
453 event_rx: rx,
454 is_streaming: false,
455 pending_submit: None,
456 pending_compact: None,
457 pending_auto_compact: false,
458 agent: None,
459 forward_handle: None,
460 oauth_join_handle: None,
461 pending_oauth_provider: None,
462 pending_command_result: None,
463 overlay_result_signal: Rc::new(RefCell::new(None)),
464 pending_scoped_ids: Rc::new(RefCell::new(None)),
465 hide_thinking: config.hide_thinking,
466 collapse_tool_output: config.collapse_tool_output,
467 tools_expanded: !config.collapse_tool_output,
468 scroll_offset: 0,
469 last_clear_time: std::time::Instant::now(),
470
471 should_quit: false,
472 pending_tool_executions: 0,
473 bash_abort_handle: None,
474 session: Some(agent_session),
475 footer,
476 footer_provider,
477 working: WorkingIndicator::new(),
478 extensions: Arc::new(config.extensions),
479
480 skills: config.skills,
481 session_info: config.session_info,
482 api_key: config.api_key,
483 scoped_model_ids: config.settings.enabled_models.clone(),
484 settings: config.settings,
485 auto_compact: true,
486 status_text: None,
487 header: Rc::new(RefCell::new(
488 crate::agent::ui::components::HeaderComponent::new(),
489 )),
490 session_picker: None,
491 last_status_len: None,
492 };
493
494 result.update_session_info();
496
497 if let Some(ref mut s) = result.session {
499 result.footer.borrow_mut().refresh_from_session(s.session());
500 }
501
502 result
503 }
504
505 fn update_session_info(&self) {
507 if let Some(ref session) = self.session
508 && let Some(ref info) = self.session_info
509 {
510 let si = crate::builtin::commands::compute_session_info(session.session());
511 if let Ok(mut guard) = info.lock() {
512 *guard = Some(si);
513 }
514 }
515 }
516
517 fn refresh_git_branch(&self) {
520 self.footer_provider.borrow_mut().refresh_git_branch();
521 }
522
523 fn clear_session_state(&mut self) {
525 self.chat_container.borrow_mut().clear();
526 self.streaming_component = None;
527 self.pending_tools.clear();
528 self.tool_call_start_times.clear();
529 self.pending_submit = None;
530 }
531
532 fn rebuild_from_session_context(&mut self) {
535 if let Some(ref agent_session) = self.session {
536 let context = agent_session.session().build_session_context();
537 {
538 let mut chat = self.chat_container.borrow_mut();
539 rebuild_chat_from_messages(
540 &mut chat,
541 &context.messages,
542 &self.cwd.to_string_lossy(),
543 self.hide_thinking,
544 self.collapse_tool_output,
545 &self.extensions,
546 );
547 }
548 if let Some(ref mut agent) = self.agent {
549 agent.replace_messages(context.messages);
550 }
551 }
552 }
553
554 fn record_model_change(&mut self, model: &str) {
556 if let Some(ref mut agent_session) = self.session {
557 agent_session.on_model_change(&self.current_provider, model);
558 }
559 if let Some(ref session) = self.session {
560 self.footer
561 .borrow_mut()
562 .refresh_from_session(session.session());
563 }
564 }
565
566 fn refresh_registry(&mut self) {
569 match provider::ProviderRegistry::load(&provider::get_agent_dir()) {
570 Ok(new_reg) => self.registry = Arc::new(new_reg),
571 Err(e) => {
572 self.status_text = Some(format!("Failed to refresh registry: {}", e));
573 }
574 }
575 }
576
577 fn propagate_hide_thinking(&mut self) {
579 let hide = self.hide_thinking;
580 {
581 let mut chat = self.chat_container.borrow_mut();
582 for child in chat.children_mut().iter_mut() {
583 child.set_hide_thinking(hide);
584 }
585 }
586 if let Some(weak) = self.streaming_component.as_ref().and_then(|w| w.upgrade()) {
587 weak.borrow_mut().set_hide_thinking(hide);
588 }
589 }
590
591 fn switch_to_session(&mut self, new_session: AgentSession) {
593 let ctx = new_session.session().build_session_context();
594 self.clear_session_state();
595 rebuild_chat_from_messages(
596 &mut self.chat_container.borrow_mut(),
597 &ctx.messages,
598 &self.cwd.to_string_lossy(),
599 self.hide_thinking,
600 self.collapse_tool_output,
601 &self.extensions,
602 );
603 self.footer
605 .borrow_mut()
606 .refresh_from_session(new_session.session());
607
608 self.session = Some(new_session);
609 self.agent = None;
610 self.update_session_info();
611 }
612}
613
614pub async fn run(config: AppConfig, session: AgentSession) -> anyhow::Result<()> {
616 crate::agent::ui::theme::init_theme(Some("dark"), false);
618
619 let mut term = ProcessTerminal::new();
620 let mut stdout = std::io::stdout();
621
622 term.start(&mut stdout)?;
626 term.hide_cursor(&mut stdout)?;
627 term.set_color_scheme_notifications(&mut stdout, true)?;
628 crate::tui::terminal::start_stdin_reader();
629
630 let mut tui = TUI::new();
631 tui.set_clear_on_shrink(false);
634 let mut app = App::new(config, session);
635
636 app.editor.borrow_mut().editor.set_focused(true);
638
639 tui.root.add_child(std::boxed::Box::new(
642 crate::tui::components::RcRefCellComponent(
643 app.header.clone() as Rc<RefCell<dyn Component>>,
644 ),
645 ));
646 tui.root.add_child(std::boxed::Box::new(
647 crate::tui::components::RcRefCellComponent(app.chat_container.clone()
648 as std::rc::Rc<std::cell::RefCell<dyn crate::tui::Component>>),
649 ));
650 tui.root.add_child(std::boxed::Box::new(
651 crate::tui::components::RcRefCellComponent(app.status_section.clone()
652 as std::rc::Rc<std::cell::RefCell<dyn crate::tui::Component>>),
653 ));
654 tui.root.add_child(std::boxed::Box::new(
655 crate::tui::components::RcRefCellComponent(app.working_section.clone()
656 as std::rc::Rc<std::cell::RefCell<dyn crate::tui::Component>>),
657 ));
658 tui.root
659 .add_child(std::boxed::Box::new(EditorComponent(app.editor.clone())));
660 tui.root
661 .add_child(std::boxed::Box::new(FooterComponent(app.footer.clone())));
662
663 app.editor.borrow_mut().update_border_color(
665 app.thinking_level.as_deref(),
666 &app.theme as &dyn crate::tui::Theme,
667 );
668
669 let mut cols: u16 = 80;
672 let mut rows: u16 = 24;
673 let mut dirty = true; loop {
676 let mut had_event = false;
681 while let Ok(event) = app.event_rx.try_recv() {
682 handle_agent_event(&mut app, event);
683 had_event = true;
684 }
685 if had_event {
686 dirty = true;
687 }
688
689 loop {
693 match terminal::try_recv_terminal_event() {
694 Some(terminal::TerminalEvent::Key(key)) => {
695 if !tui.route_input(&key) {
697 handle_input(&mut app, &mut tui, &mut term, &key);
698 }
699 }
700 Some(terminal::TerminalEvent::Paste(content)) => {
701 if !tui.route_paste(&content) {
704 app.editor.borrow_mut().editor.handle_paste(&content);
705 }
706 }
707 Some(terminal::TerminalEvent::Resize(w, h)) => {
708 app.editor.borrow_mut().editor.set_terminal_rows(h as usize);
709 tui.set_dimensions(w as usize, h as usize);
710 }
711 None => break,
712 }
713 dirty = true;
714 }
715
716 if let Some(ids) = app.pending_scoped_ids.borrow_mut().take() {
720 let auth_count = app.registry.list_authenticated_model_ids().len();
721 if ids.is_empty() || ids.len() >= auth_count {
722 app.scoped_model_ids = None;
723 } else {
724 app.scoped_model_ids = Some(ids);
725 }
726 dirty = true;
727 }
728
729 if tui.has_overlays() {
731 let result = app.overlay_result_signal.borrow_mut().take();
732 if let Some(result) = result {
733 tui.pop_overlay();
734 match result {
735 OverlayResult::ModelSelected(full_id) => {
736 if !full_id.is_empty() {
737 let (provider, model_id) = full_id
738 .split_once('/')
739 .map(|(p, m)| (p.to_string(), m.to_string()))
740 .unwrap_or_else(|| (String::new(), full_id.clone()));
741 app.current_provider = provider;
742 app.model = model_id.clone();
743 app.record_model_change(&model_id);
744 app.status_text = Some(format!("Model: {}", full_id));
745 }
746 }
747 OverlayResult::ScopedModelsAccepted(ids) => {
748 match ids {
749 Some(ids)
750 if !ids.is_empty()
751 && ids.len()
752 < app.registry.list_authenticated_model_ids().len() =>
753 {
754 app.scoped_model_ids = Some(ids.clone());
755 app.settings.set_enabled_models(Some(ids));
757 if let Err(e) = app.settings.save() {
758 app.status_text =
759 Some(format!("Failed to save model scope: {}", e));
760 } else {
761 app.status_text = Some("Model scope saved to settings".into());
762 }
763 }
764 _ => {
765 app.scoped_model_ids = None;
767 app.settings.set_enabled_models(None);
768 if let Err(e) = app.settings.save() {
769 app.status_text =
770 Some(format!("Failed to save model scope: {}", e));
771 } else if ids.is_some() {
772 app.status_text = Some("Model scope saved to settings".into());
773 }
774 }
775 }
776 }
777 OverlayResult::ScopedModelsCancelled => {
778 }
780 OverlayResult::LoginAuthTypeSelected(auth_type) => {
781 show_login_provider_selector(&mut app, &mut tui, Some(auth_type));
783 }
784 OverlayResult::LoginProviderSelected(provider_id) => {
785 if crate::provider::oauth::get(&provider_id).is_some() {
787 show_oauth_login_dialog(&mut app, &mut tui, &provider_id);
789 } else {
790 show_api_key_login_dialog(&mut app, &mut tui, &provider_id);
792 }
793 }
794 OverlayResult::LoginApiKeyProvided { provider, key } => {
795 if let Some(err_msg) = key.strip_prefix("OAUTH_LOGIN_FAILED:") {
797 app.status_text = Some(format!("OAuth login failed: {}", err_msg));
798 } else {
799 match auth::login(&provider, &key) {
800 Ok(_) => {
801 app.status_text = Some(format!("Logged in to {}", provider));
802 app.refresh_registry();
803 complete_login(&mut app, &provider, AuthType::ApiKey);
804 }
805 Err(e) => {
806 app.status_text = Some(format!("Login failed: {}", e));
807 }
808 }
809 }
810 }
811 OverlayResult::LogoutProviderSelected(provider_id) => {
812 match auth::logout(Some(&provider_id)) {
813 Ok(true) => {
814 app.status_text = Some(format!("Logged out from {}", provider_id));
815 app.refresh_registry();
816 }
817 Ok(false) => {
818 app.status_text =
819 Some(format!("No credentials for {}", provider_id));
820 }
821 Err(e) => {
822 app.status_text = Some(format!("Logout failed: {}", e));
823 }
824 }
825 }
826 }
827 }
828 dirty = true;
829 }
830
831 while let Ok(event) = app.event_rx.try_recv() {
838 handle_agent_event(&mut app, event);
839 dirty = true;
840 }
841
842 if app.forward_handle.as_ref().is_some_and(|h| h.is_finished()) {
848 app.forward_handle.take();
849 if let Some(ref mut agent) = app.agent {
850 agent.finish().await;
852 }
853 }
854
855 if app
857 .oauth_join_handle
858 .as_ref()
859 .is_some_and(|h| h.is_finished())
860 {
861 app.oauth_join_handle.take();
862
863 let oauth_provider = app.pending_oauth_provider.take();
868 if let Some(ref provider_id) = oauth_provider
869 && let Ok(Some(auth::AuthCredential::Oauth { .. })) =
870 auth::read_credential(provider_id)
871 {
872 let provider_name = app
873 .registry
874 .list_providers()
875 .into_iter()
876 .find(|(id, _)| id == provider_id)
877 .map(|(_, name)| name)
878 .unwrap_or_else(|| provider_id.clone());
879 let msg = format!("✓ Logged in to {} via OAuth", provider_name);
880 app.status_text = Some(msg.clone());
881 chat_info(&mut app, &msg);
882 app.refresh_registry();
883 complete_login(
884 &mut app,
885 provider_id,
886 crate::agent::ui::components::oauth_selector::AuthType::OAuth,
887 );
888 } else if oauth_provider.is_some() {
889 let err_msg = app.status_text.clone().unwrap_or_default();
892 if !err_msg.is_empty() {
893 chat_info(&mut app, &err_msg);
894 }
895 }
896 }
897
898 if !app.is_streaming
903 && let Some(text) = app.pending_submit.take()
904 {
905 start_agent_loop(&mut app, text).await;
906 dirty = true;
907 }
908
909 if let Some(custom_instructions) = app.pending_compact.take() {
911 handle_compact_command(&mut app, custom_instructions).await;
912 dirty = true;
913 }
914
915 if app.pending_auto_compact {
919 app.pending_auto_compact = false;
920 handle_auto_compact(&mut app).await;
921 dirty = true;
922 }
923
924 if let Some(result) = app.pending_command_result.take() {
926 match result {
927 CommandResult::ShowHelp => {
928 show_help_overlay(&mut app, &mut tui);
929 }
930 CommandResult::OpenSessionSelector => {
931 let mut picker = crate::agent::ui::components::SessionPicker::new();
933 let repo = crate::agent::DefaultSessionRepo::new();
934 picker.load_sessions(&repo);
935 app.session_picker = Some(picker);
936 app.status_text = None;
937 }
938 CommandResult::OpenModelSelector => {
939 open_model_selector(&mut app, &mut tui);
940 }
941 CommandResult::OpenSettings => {
942 chat_info(&mut app, "Settings menu - not yet implemented.");
943 }
944 CommandResult::ScopedModels => {
945 open_scoped_models_selector(&mut app, &mut tui);
946 }
947 CommandResult::Login {
948 ref provider,
949 ref api_key,
950 } => {
951 if let (Some(provider), Some(key)) = (provider, api_key) {
952 handle_login(&mut app, provider, Some(key));
953 } else if let Some(provider) = provider {
954 show_api_key_login_dialog(&mut app, &mut tui, provider);
956 } else {
957 show_auth_type_or_provider_selector(&mut app, &mut tui);
959 }
960 }
961 CommandResult::Logout { provider } => match provider {
962 Some(p) => handle_logout(&mut app, Some(&p)),
963 None => show_logout_provider_selector(&mut app, &mut tui),
964 },
965 _ => {}
966 }
967 dirty = true;
968 }
969
970 app.invalidate_rxs.retain_mut(|rx| {
972 if rx.try_recv().is_ok() {
973 dirty = true;
974 true
975 } else {
976 !rx.is_closed()
977 }
978 });
979
980 if dirty && let Ok((w, h)) = term.size() {
983 app.editor.borrow_mut().editor.set_terminal_rows(h as usize);
984 cols = w;
985 rows = h;
986 }
987
988 if app.working.tick() {
990 dirty = true;
991 }
992
993 let mut tools_to_remove: Vec<String> = Vec::new();
995 for (id, weak) in app.pending_tools.iter() {
996 if let Some(comp) = weak.upgrade() {
997 if comp.borrow_mut().tick_timer() {
998 dirty = true;
999 }
1000 } else {
1001 tools_to_remove.push(id.clone());
1002 }
1003 }
1004 for id in tools_to_remove {
1005 app.pending_tools.remove(&id);
1006 }
1007
1008 if dirty {
1010 compose_ui(&mut app, cols as usize);
1012 tui.set_dimensions(cols as usize, rows as usize);
1013 tui.render(cols as usize, rows as usize, &mut stdout)?;
1014 dirty = false;
1015 }
1016
1017 tokio::time::sleep(if dirty || app.is_streaming || app.working.should_show() {
1021 Duration::from_millis(16)
1022 } else {
1023 Duration::from_millis(50)
1024 })
1025 .await;
1026
1027 app.status_text = None;
1029
1030 if app.should_quit {
1031 if let Some(handle) = app.oauth_join_handle.take() {
1033 handle.abort();
1034 }
1035 break;
1036 }
1037 }
1038
1039 tui.finalize(&mut stdout)?;
1042 term.set_color_scheme_notifications(&mut stdout, false)?;
1043 term.show_cursor(&mut stdout)?;
1044 term.stop(&mut stdout)?;
1045
1046 Ok(())
1047}
1048
1049fn compose_ui(app: &mut App, width: usize) {
1055 if let Some(ref picker) = app.session_picker {
1057 let (_lines, _cursor_y) = picker.render(width, &app.theme as &dyn crate::tui::Theme);
1058 app.chat_container.borrow_mut().clear();
1060 app.status_section.borrow_mut().set_lines(vec![]);
1061 app.working_section.borrow_mut().set_lines(vec![]);
1062 return;
1063 }
1064
1065 let mut status_lines = Vec::new();
1067 if let Some(ref status) = app.status_text {
1068 let line = app.theme.fg_key(ThemeKey::Dim, &format!(" {}", status));
1069 status_lines.push(crate::agent::ui::render_utils::pad_to_width(&line, width));
1070 }
1071
1072 if app.is_streaming {
1074 if let Some(ref msg) = app.pending_submit {
1076 let preview = if msg.len() > 60 {
1077 format!("{}…", &msg[..60])
1078 } else {
1079 msg.clone()
1080 };
1081 let line = app
1082 .theme
1083 .fg_key(ThemeKey::Dim, &format!(" 📝 queued: {}", preview));
1084 status_lines.push(crate::agent::ui::render_utils::pad_to_width(&line, width));
1085 }
1086 }
1087 app.status_section.borrow_mut().set_lines(status_lines);
1088
1089 let mut working_lines = Vec::new();
1091 let wl = app.working.render(width);
1092 working_lines.extend(wl);
1093 app.working_section.borrow_mut().set_lines(working_lines);
1094}
1095
1096fn user_agent_message(text: &str) -> yoagent::types::AgentMessage {
1098 yoagent::types::AgentMessage::Llm(yoagent::types::Message::User {
1099 content: vec![yoagent::types::Content::Text {
1100 text: text.to_string(),
1101 }],
1102 timestamp: yoagent::types::now_ms(),
1103 })
1104}
1105
1106fn handle_input(app: &mut App, tui: &mut TUI, term: &mut ProcessTerminal, key: &KeyEvent) {
1115 if app.session_picker.is_some() {
1117 handle_session_picker_input(app, key);
1118 return;
1119 }
1120
1121 if tui.has_overlays() && matches!(key.code, crossterm::event::KeyCode::Esc) {
1126 tui.pop_overlay();
1127 return;
1128 }
1129 if tui.has_overlays() {
1130 return;
1132 }
1133
1134 if tui.root.handle_input(key) {
1139 return;
1140 }
1141
1142 let action = app.editor.borrow_mut().handle_input(key);
1145 match action {
1146 InputAction::Handled => {}
1147 InputAction::Escape => {
1148 if app.is_streaming {
1150 interrupt_streaming(app);
1151 } else {
1152 app.editor.borrow_mut().editor.set_text("");
1153 }
1154 }
1155 InputAction::Clear => {
1156 handle_clear(app);
1157 }
1158 InputAction::Exit => {
1159 app.should_quit = true;
1160 }
1161 InputAction::ThinkingCycle => {
1162 handle_thinking_cycle(app);
1163 }
1164 InputAction::ModelSelector => {
1165 open_model_selector(app, tui);
1166 }
1167 InputAction::ModelCycleForward => {
1168 handle_model_cycle(app, 1);
1169 }
1170 InputAction::ModelCycleBackward => {
1171 handle_model_cycle(app, -1);
1172 }
1173 InputAction::ToggleThinking => {
1174 app.hide_thinking = !app.hide_thinking;
1175 app.propagate_hide_thinking();
1177 app.settings.set_hide_thinking(Some(app.hide_thinking));
1179 if let Err(e) = app.settings.save() {
1180 app.status_text = Some(format!("Failed to save thinking visibility: {}", e));
1181 }
1182 show_status(
1183 app,
1184 if app.hide_thinking {
1185 "Thinking blocks: hidden".to_string()
1186 } else {
1187 "Thinking blocks: visible".to_string()
1188 },
1189 );
1190 }
1191 InputAction::ToolsExpand => {
1192 handle_tools_expand(app);
1193 }
1194 InputAction::EditorExternal => {
1195 handle_editor_external(app, tui, term);
1196 }
1197 InputAction::Help => {
1198 show_help_overlay(app, tui);
1199 }
1200 InputAction::Submit(text) => {
1201 submit_message(app, text);
1202 }
1203 InputAction::FollowUp(text) => {
1204 handle_follow_up(app, text);
1205 }
1206 InputAction::Dequeue => {
1207 if let Some(msg) = app.pending_submit.take() {
1209 app.editor.borrow_mut().editor.set_text(&msg);
1210 app.status_text = Some("Queued message restored to editor".into());
1211 } else {
1212 app.status_text = Some("No queued message".into());
1213 }
1214 }
1215 InputAction::CompactToggle => {
1216 handle_compact_toggle(app);
1217 }
1218 }
1219}
1220
1221fn handle_clear(app: &mut App) {
1227 let now = std::time::Instant::now();
1228 let elapsed = now.duration_since(app.last_clear_time);
1229 app.last_clear_time = now;
1230
1231 if app.is_streaming {
1232 interrupt_streaming(app);
1233 } else if elapsed.as_millis() < 500 {
1234 app.should_quit = true;
1236 } else {
1237 app.editor.borrow_mut().editor.set_text("");
1238 app.status_text = Some("Cleared".into());
1239 }
1240}
1241
1242fn handle_thinking_cycle(app: &mut App) {
1244 if app.available_models.is_empty() && app.model.is_empty() {
1245 app.status_text = Some("No model selected".into());
1246 return;
1247 }
1248
1249 let levels = available_thinking_levels(app);
1250 if levels.is_empty() {
1251 return;
1252 }
1253
1254 let current = app.thinking_level.as_deref().unwrap_or("off");
1255 let next = match levels.iter().position(|&l| l == current) {
1256 Some(pos) => levels[(pos + 1) % levels.len()],
1257 None => "off",
1258 };
1259
1260 app.thinking_level = Some(next.to_string());
1261 app.editor
1262 .borrow_mut()
1263 .update_border_color(Some(next), &app.theme as &dyn crate::tui::Theme);
1264 app.settings
1265 .set_default_thinking_level(Some(next.to_string()));
1266 if let Err(e) = app.settings.save() {
1267 app.status_text = Some(format!("Failed to save thinking level: {}", e));
1268 }
1269 if let Some(ref mut agent_session) = app.session {
1271 agent_session.on_thinking_level_change(next);
1272 }
1273 if let Some(ref s) = app.session {
1274 app.footer.borrow_mut().refresh_from_session(s.session());
1275 }
1276 show_status(app, format!("Thinking level: {}", next));
1277}
1278
1279fn handle_model_cycle(app: &mut App, dir: isize) {
1282 let authenticated_models = app.registry.list_authenticated_model_ids();
1285 let model_pool: Vec<String> = if let Some(ref scoped) = app.scoped_model_ids
1286 && !scoped.is_empty()
1287 {
1288 scoped
1291 .iter()
1292 .filter_map(|full_id| {
1293 let (_provider, model_id) = full_id.split_once('/')?;
1294 if authenticated_models.iter().any(|m| m == model_id) {
1295 Some(model_id.to_string())
1296 } else {
1297 None
1298 }
1299 })
1300 .collect()
1301 } else {
1302 authenticated_models
1303 };
1304
1305 let n = model_pool.len();
1306 if n == 0 {
1307 app.status_text = Some("No models available".into());
1308 return;
1309 }
1310
1311 let current_idx = model_pool.iter().position(|m| m == &app.model);
1312
1313 let next_idx = match current_idx {
1314 Some(idx) => (idx as isize + dir).rem_euclid(n as isize) as usize,
1315 None => 0,
1316 };
1317
1318 let model = model_pool[next_idx].clone();
1319 app.model = model.clone();
1320 app.current_provider = app
1321 .registry
1322 .provider_for_model(&model, Some(&app.current_provider))
1323 .unwrap_or_default();
1324 app.record_model_change(&model);
1325 show_status(app, format!("Model: {}", app.model));
1326}
1327
1328fn handle_tools_expand(app: &mut App) {
1332 app.tools_expanded = !app.tools_expanded;
1333 app.collapse_tool_output = !app.tools_expanded;
1334
1335 app.header.borrow_mut().set_expanded(app.tools_expanded);
1338
1339 let mut chat = app.chat_container.borrow_mut();
1341 for child in chat.children_mut().iter_mut() {
1342 child.set_expanded(app.tools_expanded);
1343 }
1344 drop(chat);
1345
1346 app.settings
1347 .set_collapse_tool_output(Some(app.collapse_tool_output));
1348 if let Err(e) = app.settings.save() {
1349 app.status_text = Some(format!("Failed to save tool output setting: {}", e));
1350 }
1351 show_status(
1352 app,
1353 if app.tools_expanded {
1354 "Tool output: expanded".to_string()
1355 } else {
1356 "Tool output: collapsed".to_string()
1357 },
1358 );
1359}
1360
1361fn handle_editor_external(app: &mut App, tui: &mut TUI, term: &mut ProcessTerminal) {
1364 let editor_cmd = std::env::var("VISUAL")
1365 .or_else(|_| std::env::var("EDITOR"))
1366 .unwrap_or_default();
1367
1368 if editor_cmd.is_empty() {
1369 app.status_text = Some("No editor configured. Set $VISUAL or $EDITOR.".into());
1370 return;
1371 }
1372
1373 let tmp_dir = std::env::temp_dir();
1374 let tmp_file = tmp_dir.join(format!(
1375 "rab-editor-{}.md",
1376 std::time::SystemTime::now()
1377 .duration_since(std::time::UNIX_EPOCH)
1378 .map(|d| d.as_nanos())
1379 .unwrap_or(0)
1380 ));
1381
1382 let current_text = app.editor.borrow().editor.get_text();
1383 if let Err(e) = std::fs::write(&tmp_file, ¤t_text) {
1384 app.status_text = Some(format!("Failed to write temp file: {}", e));
1385 return;
1386 }
1387
1388 let parts: Vec<&str> = editor_cmd.split(' ').collect();
1389 let (editor, args) = parts.split_first().unwrap_or((&"", &[]));
1390
1391 app.status_text = Some(format!("Opening {} ...", editor_cmd));
1393 let mut suspend_buf = Vec::new();
1394 let _ = term.stop(&mut suspend_buf);
1395 let _ = term.show_cursor(&mut suspend_buf);
1396 if !suspend_buf.is_empty() {
1397 let stdout = std::io::stdout();
1398 let mut handle = stdout.lock();
1399 let _ = handle.write_all(&suspend_buf);
1400 let _ = handle.flush();
1401 }
1402
1403 crate::tui::terminal::stop_stdin_reader();
1405 crate::tui::terminal::join_stdin_reader();
1406
1407 let status = std::process::Command::new(editor)
1409 .args(args)
1410 .arg(&tmp_file)
1411 .status();
1412
1413 let mut resume_buf = Vec::new();
1415 let _ = term.start(&mut resume_buf);
1416 let _ = term.hide_cursor(&mut resume_buf);
1417 if !resume_buf.is_empty() {
1418 let stdout = std::io::stdout();
1419 let mut handle = stdout.lock();
1420 let _ = handle.write_all(&resume_buf);
1421 let _ = handle.flush();
1422 }
1423 crate::tui::terminal::start_stdin_reader();
1425 tui.request_render();
1427
1428 match status {
1429 Ok(status) if status.success() => {
1430 if let Ok(new_content) = std::fs::read_to_string(&tmp_file) {
1431 let trimmed = new_content.trim_end_matches('\n').to_string();
1432 app.editor.borrow_mut().editor.set_text(&trimmed);
1433 app.editor.borrow_mut().check_autocomplete();
1434 }
1435 let _ = std::fs::remove_file(&tmp_file);
1436 app.status_text = Some("Editor closed".into());
1437 }
1438 Ok(_) => {
1439 let _ = std::fs::remove_file(&tmp_file);
1440 app.status_text = Some("Editor exited with non-zero status".into());
1441 }
1442 Err(e) => {
1443 let _ = std::fs::remove_file(&tmp_file);
1444 app.status_text = Some(format!("Failed to launch editor: {}", e));
1445 }
1446 }
1447}
1448
1449fn handle_compact_toggle(app: &mut App) {
1452 app.auto_compact = !app.auto_compact;
1453 app.footer.borrow_mut().set_auto_compact(app.auto_compact);
1454
1455 if let Some(ref mut s) = app.session {
1457 s.set_auto_compact(app.auto_compact);
1458 }
1459
1460 app.settings.set_auto_compact(Some(app.auto_compact));
1462 if let Err(e) = app.settings.save() {
1463 eprintln!("Warning: failed to save auto_compact setting: {}", e);
1464 }
1465
1466 app.status_text = Some(if app.auto_compact {
1467 "Auto-compact: on".into()
1468 } else {
1469 "Auto-compact: off".into()
1470 });
1471}
1472
1473pub fn handle_follow_up(app: &mut App, text: String) {
1477 let trimmed = text.trim().to_string();
1478 if trimmed.is_empty() {
1479 return;
1480 }
1481
1482 if app.is_streaming && app.agent.as_ref().is_some_and(|a| a.is_streaming()) {
1483 let follow_msg = user_agent_message(&trimmed);
1484 if let Some(ref agent) = app.agent {
1485 agent.follow_up(follow_msg);
1486 app.status_text = Some("Follow-up queued — will send when agent finishes".into());
1487 }
1488 } else {
1489 if app.is_streaming {
1491 app.is_streaming = false;
1492 }
1493 submit_message(app, trimmed);
1494 }
1495}
1496
1497fn interrupt_streaming(app: &mut App) {
1499 if let Some(ref agent) = app.agent {
1501 agent.abort();
1502 }
1503 if let Some(handle) = app.forward_handle.take() {
1505 handle.abort();
1506 }
1507 if let Some(handle) = app.bash_abort_handle.take() {
1508 handle.abort();
1509 }
1510 app.agent = None;
1513 app.is_streaming = false;
1514 app.working.stop();
1515 app.footer.borrow_mut().set_streaming(false);
1516
1517 if let Some(ref s) = app.session {
1519 let ctx = s.session().build_session_context();
1520 let mut chat = app.chat_container.borrow_mut();
1521 rebuild_chat_from_messages(
1522 &mut chat,
1523 &ctx.messages,
1524 &app.cwd.to_string_lossy(),
1525 app.hide_thinking,
1526 app.collapse_tool_output,
1527 &app.extensions,
1528 );
1529 }
1530
1531 app.status_text = Some("Interrupted".into());
1532}
1533
1534fn handle_login(app: &mut App, provider: &str, api_key: Option<&str>) {
1539 let provider = if provider.is_empty() {
1540 "opencode-go"
1541 } else {
1542 provider
1543 };
1544 if let Some(key) = api_key {
1545 match auth::login(provider, key) {
1546 Ok(_) => {
1547 app.refresh_registry();
1548 complete_login(
1550 app,
1551 provider,
1552 crate::agent::ui::components::oauth_selector::AuthType::ApiKey,
1553 );
1554 }
1555 Err(e) => chat_info(app, format!("Login failed: {}", e)),
1556 }
1557 } else {
1558 chat_info(app, format!("Usage: /login {} <api-key>", provider));
1559 }
1560}
1561
1562fn handle_logout(app: &mut App, provider: Option<&str>) {
1564 match auth::logout(provider) {
1565 Ok(true) => {
1566 let msg = provider
1567 .map(|p| format!("Logged out from {}", p))
1568 .unwrap_or_else(|| "Logged out from all providers".into());
1569 chat_info(app, msg);
1570 }
1571 Ok(false) => {
1572 let msg = provider
1573 .map(|p| format!("No credentials for {}", p))
1574 .unwrap_or_else(|| "No credentials found".into());
1575 chat_info(app, msg);
1576 }
1577 Err(e) => {
1578 chat_info(app, format!("Logout failed: {}", e));
1579 }
1580 }
1581}
1582
1583fn show_login_provider_selector(app: &mut App, tui: &mut TUI, auth_type: Option<AuthType>) {
1586 use crate::agent::ui::components::oauth_selector::{
1587 AuthSelectorProvider, AuthType, OAuthSelector, SelectorMode,
1588 };
1589
1590 let all_providers = app.registry.list_providers();
1591
1592 let mut providers: Vec<AuthSelectorProvider> = Vec::new();
1594
1595 for (id, name) in all_providers {
1597 let is_oauth_provider = crate::provider::oauth::get(&id).is_some();
1598 match auth_type {
1599 Some(AuthType::ApiKey) => {
1600 if !is_oauth_provider {
1602 providers.push(AuthSelectorProvider {
1603 id,
1604 name,
1605 auth_type: AuthType::ApiKey,
1606 });
1607 }
1608 }
1609 Some(AuthType::OAuth) => {
1610 if is_oauth_provider {
1612 providers.push(AuthSelectorProvider {
1613 id,
1614 name,
1615 auth_type: AuthType::OAuth,
1616 });
1617 }
1618 }
1619 None => {
1620 providers.push(AuthSelectorProvider {
1621 id,
1622 name,
1623 auth_type: if is_oauth_provider {
1624 AuthType::OAuth
1625 } else {
1626 AuthType::ApiKey
1627 },
1628 });
1629 }
1630 }
1631 }
1632
1633 if auth_type != Some(AuthType::ApiKey) {
1635 for oauth_id in crate::provider::oauth::list_ids() {
1636 if !providers.iter().any(|p| p.id == oauth_id)
1637 && let Some(provider) = crate::provider::oauth::get(&oauth_id)
1638 {
1639 providers.push(AuthSelectorProvider {
1640 id: oauth_id,
1641 name: provider.name().to_string(),
1642 auth_type: AuthType::OAuth,
1643 });
1644 }
1645 }
1646 }
1647
1648 providers.sort_by_key(|a| a.name.to_lowercase());
1650
1651 if providers.is_empty() {
1652 app.status_text = Some(match auth_type {
1653 Some(AuthType::OAuth) => "No subscription providers available.".into(),
1654 Some(AuthType::ApiKey) => "No API key providers available.".into(),
1655 None => "No providers available.".into(),
1656 });
1657 return;
1658 }
1659
1660 let signal = app.overlay_result_signal.clone();
1661 let mut selector = OAuthSelector::new(
1662 providers,
1663 |provider_id| app.registry.auth_status_for_provider(provider_id),
1664 SelectorMode::Login,
1665 );
1666
1667 selector.on_select(move |provider_id: String| {
1668 *signal.borrow_mut() = Some(OverlayResult::LoginProviderSelected(provider_id));
1669 });
1670 selector.on_cancel(|| {});
1671
1672 tui.show_top_overlay(Box::new(selector));
1673}
1674
1675fn show_api_key_login_dialog(app: &mut App, tui: &mut TUI, provider_id: &str) {
1678 use crate::agent::ui::components::LoginDialog;
1679
1680 let provider_name = app
1682 .registry
1683 .list_providers()
1684 .into_iter()
1685 .find(|(id, _)| id == provider_id)
1686 .map(|(_, name)| name)
1687 .unwrap_or_else(|| provider_id.to_string());
1688
1689 let mut dialog = LoginDialog::new(provider_id.to_string(), provider_name.clone());
1690
1691 let signal = app.overlay_result_signal.clone();
1692 let provider_id_clone = provider_id.to_string();
1693
1694 dialog.on_submit(move |api_key: String| {
1695 *signal.borrow_mut() = Some(OverlayResult::LoginApiKeyProvided {
1696 provider: provider_id_clone,
1697 key: api_key,
1698 });
1699 });
1700
1701 dialog.on_cancel(|| {});
1702
1703 dialog.show_prompt("Enter API key:", Some("sk-..."));
1704
1705 tui.show_top_overlay(Box::new(dialog));
1706}
1707
1708fn show_oauth_login_dialog(app: &mut App, tui: &mut TUI, provider_id: &str) {
1711 let provider_name = app
1712 .registry
1713 .list_providers()
1714 .into_iter()
1715 .find(|(id, _)| id == provider_id)
1716 .map(|(_, name)| name)
1717 .unwrap_or_else(|| {
1718 crate::provider::oauth::get(provider_id)
1719 .map(|p| p.name().to_string())
1720 .unwrap_or_else(|| provider_id.to_string())
1721 });
1722
1723 app.status_text = Some(format!("Starting OAuth login for {}…", provider_name));
1724 tui.pop_overlay(); let tx = app.event_tx.clone();
1729 let pid = provider_id.to_string();
1730 let pname = provider_name.clone();
1731
1732 let tx2 = tx.clone();
1733 let tx3 = tx.clone();
1734 let tx4 = tx.clone();
1735
1736 app.pending_oauth_provider = Some(pid.clone());
1737
1738 let handle = tokio::spawn(async move {
1739 let oauth_provider = match crate::provider::oauth::get(&pid) {
1740 Some(p) => p,
1741 None => {
1742 let _ = tx.send(yoagent::types::AgentEvent::ProgressMessage {
1743 tool_call_id: String::new(),
1744 tool_name: String::new(),
1745 text: format!(
1746 "OAuth login failed: No OAuth provider registered for '{}'",
1747 pid
1748 ),
1749 });
1750 return;
1751 }
1752 };
1753
1754 let mut callbacks = crate::provider::oauth::OAuthLoginCallbacks {
1755 on_device_code: Box::new(move |info: crate::provider::oauth::DeviceCodeInfo| {
1756 let device_msg = format!(
1757 "Open {} and enter code: {}",
1758 info.verification_uri, info.user_code
1759 );
1760 let _ = tx.send(yoagent::types::AgentEvent::ProgressMessage {
1762 tool_call_id: String::new(),
1763 tool_name: String::new(),
1764 text: device_msg,
1765 });
1766 }),
1767 on_prompt: Box::new(
1768 move |prompt: crate::provider::oauth::OAuthPrompt| match prompt {
1769 crate::provider::oauth::OAuthPrompt::Text {
1770 message,
1771 placeholder: _,
1772 allow_empty: _,
1773 } => {
1774 let _ = tx2.send(yoagent::types::AgentEvent::ProgressMessage {
1776 tool_call_id: String::new(),
1777 tool_name: String::new(),
1778 text: format!("{} (empty = github.com)", message),
1779 });
1780 Ok(String::new())
1783 }
1784 },
1785 ),
1786 on_progress: Box::new(move |msg: String| {
1787 let _ = tx3.send(yoagent::types::AgentEvent::ProgressMessage {
1788 tool_call_id: String::new(),
1789 tool_name: String::new(),
1790 text: format!("[OAuth] {}", msg),
1791 });
1792 }),
1793 signal: None,
1794 };
1795
1796 match oauth_provider.login(&mut callbacks).await {
1797 Ok(credentials) => {
1798 let cred = crate::auth::AuthCredential::Oauth {
1799 access: credentials.access.clone(),
1800 refresh: Some(credentials.refresh.clone()),
1801 expires: Some(credentials.expires),
1802 enterprise_url: credentials.enterprise_url.clone(),
1803 };
1804 match crate::auth::login_oauth(&pid, &cred) {
1805 Ok(_) => {
1806 let _ = tx4.send(yoagent::types::AgentEvent::ProgressMessage {
1807 tool_call_id: String::new(),
1808 tool_name: String::new(),
1809 text: format!("✓ Logged in to {} via OAuth", pname),
1810 });
1811 }
1812 Err(e) => {
1813 let _ = tx4.send(yoagent::types::AgentEvent::ProgressMessage {
1814 tool_call_id: String::new(),
1815 tool_name: String::new(),
1816 text: format!("Failed to save OAuth credentials: {}", e),
1817 });
1818 }
1819 }
1820 }
1821 Err(e) => {
1822 let _ = tx4.send(yoagent::types::AgentEvent::ProgressMessage {
1823 tool_call_id: String::new(),
1824 tool_name: String::new(),
1825 text: format!("OAuth login failed: {}", e),
1826 });
1827 }
1828 }
1829 });
1830 app.oauth_join_handle = Some(handle);
1831}
1832
1833fn show_auth_type_selector(app: &mut App, tui: &mut TUI) {
1836 let signal = app.overlay_result_signal.clone();
1838 let _theme = crate::agent::ui::theme::current_theme().clone();
1839
1840 let mut items = vec![crate::tui::components::select_list::SelectItem::new(
1841 "api_key",
1842 "Use an API key",
1843 )];
1844 let has_oauth = !crate::provider::oauth::list_ids().is_empty();
1846 if has_oauth {
1847 items.push(crate::tui::components::select_list::SelectItem::new(
1848 "oauth",
1849 "Use a subscription",
1850 ));
1851 }
1852
1853 let filtered_indices: Vec<usize> = (0..items.len()).collect();
1854 let selected_index: usize = 0;
1855
1856 struct AuthTypeOverlay {
1857 items: Vec<crate::tui::components::select_list::SelectItem>,
1858 selected_index: usize,
1859 filtered_indices: Vec<usize>,
1860 signal: std::rc::Rc<std::cell::RefCell<Option<OverlayResult>>>,
1861 }
1862
1863 impl crate::tui::Component for AuthTypeOverlay {
1864 fn render(&mut self, width: usize) -> Vec<String> {
1865 let theme = crate::agent::ui::theme::current_theme();
1866 let mut lines = Vec::new();
1867
1868 lines.push(theme.dim(&"─".repeat(width.saturating_sub(2))));
1869 lines.push(String::new());
1870 lines.push(format!(
1871 " {}",
1872 theme.bold(&theme.fg_key(ThemeKey::Accent, "Select authentication method:"))
1873 ));
1874 lines.push(String::new());
1875
1876 for (i, &item_idx) in self.filtered_indices.iter().enumerate() {
1877 let item = &self.items[item_idx];
1878 let is_selected = i == self.selected_index;
1879 let prefix = if is_selected {
1880 theme.fg_key(ThemeKey::Accent, "→ ")
1881 } else {
1882 " ".to_string()
1883 };
1884 let text = if is_selected {
1885 theme.fg_key(ThemeKey::Accent, &item.label)
1886 } else {
1887 theme.fg_key(ThemeKey::Text, &item.label)
1888 };
1889 lines.push(format!("{}{}", prefix, text));
1890 }
1891
1892 lines.push(String::new());
1893 lines.push(format!(" {}", theme.dim("Enter: select · Esc: cancel")));
1894 lines.push(String::new());
1895 lines.push(theme.dim(&"─".repeat(width.saturating_sub(2))));
1896
1897 lines
1898 }
1899
1900 fn handle_input(&mut self, key: &crossterm::event::KeyEvent) -> bool {
1901 let kb = crate::tui::keybindings::get_keybindings();
1902
1903 if kb.matches(key, crate::tui::keybindings::ACTION_SELECT_UP) {
1904 if self.filtered_indices.is_empty() {
1905 return true;
1906 }
1907 self.selected_index = if self.selected_index == 0 {
1908 self.filtered_indices.len() - 1
1909 } else {
1910 self.selected_index - 1
1911 };
1912 return true;
1913 }
1914
1915 if kb.matches(key, crate::tui::keybindings::ACTION_SELECT_DOWN) {
1916 if self.filtered_indices.is_empty() {
1917 return true;
1918 }
1919 self.selected_index = if self.selected_index >= self.filtered_indices.len() - 1 {
1920 0
1921 } else {
1922 self.selected_index + 1
1923 };
1924 return true;
1925 }
1926
1927 if kb.matches(key, crate::tui::keybindings::ACTION_SELECT_CONFIRM) {
1928 if let Some(&idx) = self.filtered_indices.get(self.selected_index) {
1929 let value = self.items[idx].value.clone();
1930 let auth_type = match value.as_str() {
1931 "oauth" => AuthType::OAuth,
1932 _ => AuthType::ApiKey,
1933 };
1934 *self.signal.borrow_mut() =
1935 Some(OverlayResult::LoginAuthTypeSelected(auth_type));
1936 }
1937 return true;
1938 }
1939
1940 if kb.matches(key, crate::tui::keybindings::ACTION_SELECT_CANCEL) {
1941 return true;
1943 }
1944
1945 false
1946 }
1947 }
1948
1949 let overlay = AuthTypeOverlay {
1950 items,
1951 selected_index,
1952 filtered_indices,
1953 signal: signal.clone(),
1954 };
1955
1956 tui.show_top_overlay(Box::new(overlay));
1957}
1958
1959fn show_auth_type_or_provider_selector(app: &mut App, tui: &mut TUI) {
1962 let providers = app.registry.list_providers();
1963 if providers.is_empty() {
1964 app.status_text = Some("No providers available for login.".into());
1965 return;
1966 }
1967 let has_oauth = !crate::provider::oauth::list_ids().is_empty();
1969 let has_api_key = providers.iter().any(|(_, _)| true);
1970 if has_oauth && has_api_key {
1971 show_auth_type_selector(app, tui);
1972 } else if has_oauth {
1973 show_login_provider_selector(app, tui, Some(AuthType::OAuth));
1974 } else {
1975 show_login_provider_selector(app, tui, Some(AuthType::ApiKey));
1976 }
1977}
1978
1979fn show_logout_provider_selector(app: &mut App, tui: &mut TUI) {
1982 use crate::agent::ui::components::oauth_selector::{
1983 AuthSelectorProvider, AuthType, OAuthSelector, SelectorMode,
1984 };
1985
1986 let logged_in = auth::list_logged_in().unwrap_or_default();
1988
1989 if logged_in.is_empty() {
1990 app.status_text = Some(
1991 "No stored credentials to remove. /logout only removes credentials saved by /login; \
1992 environment variables and models.json config are unchanged."
1993 .into(),
1994 );
1995 return;
1996 }
1997
1998 let mut providers: Vec<AuthSelectorProvider> = logged_in
1999 .into_iter()
2000 .filter_map(|id| {
2001 app.registry
2002 .list_providers()
2003 .into_iter()
2004 .find(|(pid, _)| pid == &id)
2005 .map(|(pid, name)| AuthSelectorProvider {
2006 id: pid,
2007 name,
2008 auth_type: AuthType::ApiKey,
2009 })
2010 })
2011 .collect();
2012
2013 providers.sort_by_key(|a| a.name.to_lowercase());
2015
2016 if providers.is_empty() {
2017 app.status_text = Some("No registered providers with stored credentials.".into());
2019 return;
2020 }
2021
2022 let signal = app.overlay_result_signal.clone();
2023 let mut selector = OAuthSelector::new(
2024 providers,
2025 |provider_id| app.registry.auth_status_for_provider(provider_id),
2026 SelectorMode::Logout,
2027 );
2028
2029 selector.on_select(move |provider_id: String| {
2030 *signal.borrow_mut() = Some(OverlayResult::LogoutProviderSelected(provider_id));
2031 });
2032 selector.on_cancel(|| {});
2033
2034 tui.show_top_overlay(Box::new(selector));
2035}
2036
2037fn complete_login(app: &mut App, provider_id: &str, _auth_type: AuthType) {
2040 let available_models = app.registry.list_model_provider_tuples();
2042 let provider_models: Vec<&str> = available_models
2043 .iter()
2044 .filter(|(pid, _, _)| pid == provider_id)
2045 .map(|(_, mid, _)| mid.as_str())
2046 .collect();
2047
2048 if provider_models.is_empty() {
2049 app.status_text = Some(format!(
2050 "Saved API key for {provider_id}. No models available for this provider. Use /model to select a model."
2051 ));
2052 return;
2053 }
2054
2055 let current_provider = app
2057 .registry
2058 .provider_for_model(&app.model, Some(&app.current_provider))
2059 .unwrap_or_default();
2060
2061 if current_provider != provider_id || !app.available_models.contains(&app.model) {
2062 let first_model = provider_models[0];
2063 app.model = first_model.to_string();
2064 app.current_provider = provider_id.to_string();
2065 let model = app.model.clone();
2066 app.record_model_change(&model);
2067 app.status_text = Some(format!(
2068 "Saved API key for {provider_id}. Selected {first_model}."
2069 ));
2070 } else {
2071 app.status_text = Some(format!("Saved API key for {provider_id}."));
2072 }
2073}
2074
2075fn open_model_selector(app: &mut App, tui: &mut TUI) {
2077 let current = app.model.clone();
2078
2079 let all_tuples: Vec<(String, String, String)> = app.registry.list_model_provider_tuples();
2082 let all_models: Vec<(String, String, String)> = all_tuples
2083 .into_iter()
2084 .filter(|(provider, _, _)| app.registry.provider_has_auth(provider))
2085 .collect();
2086
2087 let scoped_ids = app.scoped_model_ids.clone().unwrap_or_default();
2088
2089 let signal = app.overlay_result_signal.clone();
2090 let current_provider = app
2091 .registry
2092 .provider_for_model(¤t, Some(&app.current_provider))
2093 .unwrap_or_else(|| "unknown".to_string());
2094 let current_full_id = format!("{}/{}", current_provider, current);
2095
2096 let callbacks = crate::agent::ui::model_selector::ModelSelectorCallbacks {
2097 on_select: Box::new({
2098 let signal = signal.clone();
2099 move |full_id: String| {
2100 *signal.borrow_mut() = Some(OverlayResult::ModelSelected(full_id));
2101 }
2102 }),
2103 on_cancel: Box::new(|| {}), };
2105
2106 let selector = crate::agent::ui::model_selector::ModelSelector::new(
2107 all_models,
2108 scoped_ids,
2109 current_full_id,
2110 callbacks,
2111 );
2112 tui.show_top_overlay(Box::new(selector));
2113}
2114
2115fn open_scoped_models_selector(app: &mut App, tui: &mut TUI) {
2117 use crate::agent::ui::components::scoped_models_selector::{
2118 ModelsCallbacks, ModelsConfig, ScopedModelsSelector,
2119 };
2120
2121 let all_tuples: Vec<(String, String, String)> = app.registry.list_model_provider_tuples();
2123 let all_models: Vec<(String, String, String)> = all_tuples
2124 .into_iter()
2125 .filter(|(provider, _, _)| app.registry.provider_has_auth(provider))
2126 .collect();
2127
2128 let current_enabled = app.scoped_model_ids.clone();
2129 let change_signal = app.pending_scoped_ids.clone();
2130 let close_signal = app.overlay_result_signal.clone();
2131
2132 let callbacks = ModelsCallbacks {
2133 on_change: Box::new(move |enabled_ids: Option<Vec<String>>| {
2134 *change_signal.borrow_mut() = Some(enabled_ids.unwrap_or_default());
2136 }),
2137 on_persist: Box::new({
2138 let cs = close_signal.clone();
2139 move |enabled_ids: Option<Vec<String>>| {
2140 *cs.borrow_mut() = Some(OverlayResult::ScopedModelsAccepted(enabled_ids));
2141 }
2142 }),
2143 on_cancel: Box::new(move || {
2144 *close_signal.borrow_mut() = Some(OverlayResult::ScopedModelsCancelled);
2145 }),
2146 };
2147
2148 let config = ModelsConfig {
2149 all_models,
2150 enabled_model_ids: current_enabled,
2151 };
2152
2153 let selector = ScopedModelsSelector::new(config, callbacks);
2154 tui.show_top_overlay(Box::new(selector));
2155}
2156
2157fn show_help_overlay(app: &mut App, tui: &mut TUI) {
2158 let mut overlay = crate::agent::ui::help::HelpOverlay::new(&app.theme);
2159 overlay.set_commands(app.commands.clone());
2160 tui.show_overlay(Box::new(overlay), Default::default());
2161}
2162
2163fn submit_message(app: &mut App, message: String) {
2168 app.scroll_offset = 0;
2169 let trimmed = message.trim().to_string();
2170
2171 if trimmed.is_empty() {
2173 return;
2174 }
2175
2176 if trimmed.starts_with("/skill:") {
2178 let expanded = expand_skill_command(&trimmed, &app.skills);
2179 if app.is_streaming && app.agent.as_ref().is_some_and(|a| a.is_streaming()) {
2180 let steer_msg = user_agent_message(&expanded);
2181 if let Some(ref agent) = app.agent {
2182 agent.steer(steer_msg);
2183 app.status_text = Some("Skill steering message sent".into());
2184 }
2185 return;
2186 }
2187 if app.is_streaming {
2188 app.is_streaming = false;
2190 app.working.stop();
2191 app.footer.borrow_mut().set_streaming(false);
2192 }
2193 app.pending_submit = Some(expanded);
2194 return;
2195 }
2196
2197 if trimmed.starts_with('/') {
2199 handle_slash_command(app, &trimmed);
2200 return;
2201 }
2202
2203 if let Some((cmd, _exclude)) = parse_bang_command(&trimmed) {
2205 handle_bang_command(app, cmd);
2206 return;
2207 }
2208
2209 if app.is_streaming {
2210 if app.agent.as_ref().is_some_and(|a| a.is_streaming()) {
2215 let steer_msg = user_agent_message(&trimmed);
2216 if let Some(ref agent) = app.agent {
2217 agent.steer(steer_msg);
2218 app.status_text = Some("Steering message sent — will be processed next".into());
2219 }
2220 if let Some(ref mut s) = app.session {
2222 s.reset_overflow_recovery();
2223 }
2224 return; } else {
2226 app.is_streaming = false;
2229 app.working.stop();
2230 app.footer.borrow_mut().set_streaming(false);
2231 }
2232 }
2233
2234 if let Some(ref mut s) = app.session {
2236 s.reset_overflow_recovery();
2237 }
2238
2239 app.pending_submit = Some(trimmed);
2241}
2242
2243#[allow(clippy::too_many_arguments)]
2246fn build_fresh_agent(
2247 registry: &ProviderRegistry,
2248 model: &str,
2249 api_key: &str,
2250 system_prompt: &str,
2251 thinking_level: yoagent::types::ThinkingLevel,
2252 messages: Vec<yoagent::types::AgentMessage>,
2253 extensions: &[Box<dyn Extension>],
2254 default_provider: Option<&str>,
2255) -> yoagent::agent::Agent {
2256 use yoagent::provider::model::ApiProtocol;
2257
2258 let resolved = registry.resolve(model, default_provider).ok();
2259 let mc = resolved
2260 .as_ref()
2261 .map(|r| r.model_config.clone())
2262 .unwrap_or_else(|| crate::agent::base_model_config(model));
2263 let api_key = resolved
2264 .as_ref()
2265 .map(|r| r.api_key.as_str())
2266 .unwrap_or(api_key);
2267
2268 let tools: Vec<Box<dyn yoagent::types::AgentTool>> = extensions
2269 .iter()
2270 .flat_map(|ext| ext.tools())
2271 .map(|twm| Box::new(twm) as Box<dyn yoagent::types::AgentTool>)
2272 .collect();
2273
2274 let agent = match mc.api {
2275 ApiProtocol::OpenAiCompletions => {
2276 yoagent::agent::Agent::new(crate::provider::openai_compat::RabOpenAiCompatProvider)
2277 }
2278 ApiProtocol::AnthropicMessages => {
2279 yoagent::agent::Agent::new(crate::provider::anthropic::RabAnthropicProvider)
2280 }
2281 ApiProtocol::OpenAiResponses => {
2282 yoagent::agent::Agent::new(yoagent::provider::OpenAiResponsesProvider)
2283 }
2284 ApiProtocol::GoogleGenerativeAi => {
2285 yoagent::agent::Agent::new(yoagent::provider::GoogleProvider)
2286 }
2287 _ => yoagent::agent::Agent::new(yoagent::provider::OpenAiCompatProvider),
2288 };
2289
2290 agent
2291 .with_model(model)
2292 .with_api_key(api_key)
2293 .with_model_config(mc)
2294 .with_system_prompt(system_prompt)
2295 .with_thinking(thinking_level)
2296 .with_messages(messages)
2297 .with_tools(tools)
2298 .without_context_management()
2299}
2300
2301fn map_thinking_level(level: Option<&str>) -> yoagent::types::ThinkingLevel {
2303 match level {
2304 Some("off") => yoagent::types::ThinkingLevel::Off,
2305 Some("low") => yoagent::types::ThinkingLevel::Low,
2306 Some("medium") => yoagent::types::ThinkingLevel::Medium,
2307 Some("high") | Some("xhigh") => yoagent::types::ThinkingLevel::High,
2308 _ => yoagent::types::ThinkingLevel::High,
2309 }
2310}
2311
2312async fn start_agent_loop(app: &mut App, message: String) {
2320 if app.session.is_none() {
2321 return;
2322 }
2323
2324 app.is_streaming = true;
2325 app.working.start();
2326 app.footer.borrow_mut().set_streaming(true);
2327
2328 let thinking = map_thinking_level(app.thinking_level.as_deref());
2329
2330 let msgs = app
2334 .session
2335 .as_ref()
2336 .map(|s| s.session().build_session_context().messages)
2337 .unwrap_or_default();
2338
2339 let model = app.model.clone();
2341 app.record_model_change(&model);
2342 if let Some(ref mut session) = app.session {
2343 session.on_thinking_level_change(app.thinking_level.as_deref().unwrap_or("off"));
2344 }
2345
2346 let agent: &mut yoagent::agent::Agent = match &mut app.agent {
2347 Some(existing) => {
2348 existing
2352 }
2353 None => {
2354 let preferred = if !app.current_provider.is_empty() {
2355 Some(app.current_provider.as_str())
2356 } else {
2357 app.settings.default_provider.as_deref()
2358 };
2359 app.agent = Some(build_fresh_agent(
2360 &app.registry,
2361 &app.model,
2362 &app.api_key,
2363 &app.system_prompt,
2364 thinking,
2365 msgs,
2366 &app.extensions,
2367 preferred,
2368 ));
2369 app.agent.as_mut().unwrap()
2371 }
2372 };
2373
2374 let mut rx = agent.prompt(message).await;
2377
2378 let tx = app.event_tx.clone();
2381 let handle = tokio::spawn(async move {
2382 while let Some(event) = rx.recv().await {
2383 if tx.send(event).is_err() {
2384 break;
2385 }
2386 }
2387 });
2388 app.forward_handle = Some(handle);
2389}
2390
2391async fn handle_compact_command(app: &mut App, custom_instructions: Option<String>) {
2394 if app.session.is_none() {
2395 chat_info(app, "No active session to compact".to_string());
2396 return;
2397 }
2398
2399 let agent_session = app.session.as_mut().unwrap();
2400
2401 app.working.start();
2402
2403 match agent_session
2404 .run_manual_compact(custom_instructions.as_deref())
2405 .await
2406 {
2407 Ok(_summary) => {
2408 app.working.stop();
2409 app.status_text = None;
2410 app.rebuild_from_session_context();
2411 show_status(app, "Compaction completed".to_string());
2412 }
2413 Err(e) => {
2414 app.working.stop();
2415 app.status_text = None;
2416 chat_info(app, format!("Compaction failed: {}", e));
2417 }
2418 }
2419}
2420
2421async fn handle_auto_compact(app: &mut App) {
2425 if app.session.is_none() {
2426 return;
2427 }
2428
2429 let agent_session = app.session.as_mut().unwrap();
2430
2431 match agent_session.check_auto_compact().await {
2432 Ok(true) => {
2433 app.rebuild_from_session_context();
2434 if let Some(ref s) = app.session {
2436 app.footer.borrow_mut().refresh_from_session(s.session());
2437 }
2438 app.status_text = Some("Auto-compaction completed".to_string());
2439 }
2440 Ok(false) => {
2441 }
2443 Err(e) => {
2444 eprintln!("Warning: Auto-compaction failed: {}", e);
2445 app.status_text = Some(format!("Auto-compaction skipped: {}", e));
2446 }
2447 }
2448}
2449
2450fn handle_session_picker_input(app: &mut App, key: &crossterm::event::KeyEvent) {
2452 use crossterm::event::KeyCode;
2453
2454 let Some(ref mut picker) = app.session_picker else {
2455 return;
2456 };
2457
2458 match key.code {
2459 KeyCode::Esc => {
2460 app.session_picker = None;
2461 app.status_text = None;
2462 }
2463 KeyCode::Enter => {
2464 if let Some(path) = picker.selected_path() {
2465 let path = path.clone();
2466 app.session_picker = None;
2467 app.status_text = None;
2468 app.pending_command_result = Some(CommandResult::SessionSwitched { path });
2470 }
2471 }
2472 KeyCode::Up => {
2473 picker.select_prev();
2474 }
2475 KeyCode::Down => {
2476 picker.select_next();
2477 }
2478 KeyCode::Char('/') => {
2479 picker.set_filter("");
2480 }
2481 KeyCode::Char(c) => {
2482 let mut filter = picker.filter().to_string();
2483 filter.push(c);
2484 picker.set_filter(&filter);
2485 }
2486 KeyCode::Backspace => {
2487 let mut filter = picker.filter().to_string();
2488 filter.pop();
2489 picker.set_filter(&filter);
2490 }
2491 _ => {}
2492 }
2493}
2494
2495fn handle_slash_command(app: &mut App, input: &str) {
2500 let (cmd_name, args) = match input.split_once(' ') {
2501 Some((cmd, rest)) => (cmd.trim_start_matches('/'), rest),
2502 None => (input.trim_start_matches('/'), ""),
2503 };
2504
2505 for ext in app.extensions.iter() {
2507 for cmd in ext.commands() {
2508 if cmd.name == cmd_name {
2509 let result = cmd.handler.execute(args);
2512 match result {
2513 Ok(result) => {
2514 drop((ext, cmd));
2516 handle_command_result(app, result);
2517 return;
2518 }
2519 Err(e) => {
2520 drop((ext, cmd));
2521 chat_info(app, format!("Error executing /{}: {}", cmd_name, e));
2522 return;
2523 }
2524 }
2525 }
2526 }
2527 }
2528
2529 let available: Vec<&str> = app.commands.iter().map(|(n, _)| n.as_str()).collect();
2531 app.status_text = Some(format!(
2532 "Unknown command: /{}. Available: {}",
2533 cmd_name,
2534 available.join(", ")
2535 ));
2536}
2537
2538fn handle_command_result(app: &mut App, result: CommandResult) {
2542 match result {
2543 CommandResult::Info(msg) => {
2544 chat_info(app, msg.clone());
2545 }
2546 CommandResult::Quit => {
2547 app.should_quit = true;
2548 }
2549 CommandResult::ModelChanged(model) => {
2550 app.model = model.clone();
2551 app.current_provider = app
2552 .registry
2553 .provider_for_model(&model, Some(&app.current_provider))
2554 .unwrap_or_default();
2555 app.record_model_change(&model);
2556 app.status_text = Some(format!("Model: {}", model));
2557 }
2558 CommandResult::ShowHelp => {
2559 app.pending_command_result = Some(result);
2561 }
2562 CommandResult::Reloaded => {
2563 app.refresh_registry();
2564 if let Err(e) = app.settings.reload(&app.cwd) {
2566 app.status_text = Some(format!("Failed to reload settings: {}", e));
2567 } else {
2568 if let Some(level) = app.settings.default_thinking_level.clone() {
2570 app.thinking_level = Some(level.clone());
2571 if let Some(ref mut s) = app.session {
2572 s.on_thinking_level_change(&level);
2573 }
2574 if let Some(ref s) = app.session {
2575 app.footer.borrow_mut().refresh_from_session(s.session());
2576 }
2577 }
2579 app.hide_thinking = app.settings.hide_thinking.unwrap_or(true);
2580 app.propagate_hide_thinking();
2581 app.editor.borrow_mut().update_border_color(
2582 app.thinking_level.as_deref(),
2583 &app.theme as &dyn crate::tui::Theme,
2584 );
2585 chat_info(
2586 app,
2587 "Settings, extensions, and keybindings reloaded.".to_string(),
2588 );
2589 }
2590 }
2591 CommandResult::NewSession => {
2592 app.working.stop();
2601
2602 app.status_text = None;
2604
2605 if let Some(ref mut agent_session) = app.session {
2607 agent_session.new_session();
2608 }
2609
2610 app.agent = None;
2612 app.clear_session_state();
2613
2614 if let Some(ref s) = app.session {
2616 app.footer.borrow_mut().refresh_from_session(s.session());
2617 }
2618
2619 let styled = app.theme.fg("accent", "✓ New session started");
2622 chat_add(app, std::boxed::Box::new(Text::new(styled, 1, 1, None)));
2623 }
2624 CommandResult::SessionSwitched { path } => {
2625 let new_session = crate::agent::AgentSession::open(&path, None, Some(&app.cwd));
2626 app.switch_to_session(new_session);
2627 app.status_text = Some(format!("Switched to session: {}", path.display()));
2628 }
2629 CommandResult::SessionInfo {
2630 session_id,
2631 file_path,
2632 name,
2633 message_count,
2634 user_messages,
2635 assistant_messages,
2636 tool_calls,
2637 tool_results,
2638 total_tokens,
2639 input_tokens,
2640 output_tokens,
2641 cache_read_tokens,
2642 cache_write_tokens,
2643 cost,
2644 } => {
2645 let name_display = name
2646 .or_else(|| {
2647 app.session
2648 .as_ref()
2649 .and_then(|s| s.session().session_name())
2650 })
2651 .unwrap_or_else(|| "unnamed".to_string());
2652 let file_display = file_path
2653 .as_ref()
2654 .map(|p| p.display().to_string())
2655 .unwrap_or_else(|| "in-memory".to_string());
2656 let sid = if session_id.is_empty() {
2657 app.session
2658 .as_ref()
2659 .map(|s| s.session().session_id())
2660 .unwrap_or_default()
2661 } else {
2662 session_id
2663 };
2664
2665 let total_messages = message_count;
2666
2667 let mut info = format!(
2669 "Session Info\n\n\
2670 Name: {name_display}\n\
2671 File: {file_display}\n\
2672 ID: {sid}\n\
2673 \n\
2674 Messages\n\
2675 User: {user_messages}\n\
2676 Assistant: {assistant_messages}\n\
2677 Tool Calls: {tool_calls}\n\
2678 Tool Results: {tool_results}\n\
2679 Total: {total_messages}\n\
2680 \n\
2681 Tokens\n\
2682 Input: {}\n\
2683 Output: {}",
2684 format_number(input_tokens),
2685 format_number(output_tokens),
2686 );
2687 if cache_read_tokens > 0 {
2688 info += &format!("\nCache Read: {}", format_number(cache_read_tokens));
2689 }
2690 if cache_write_tokens > 0 {
2691 info += &format!("\nCache Write: {}", format_number(cache_write_tokens));
2692 }
2693 info += &format!("\nTotal: {}", format_number(total_tokens));
2694
2695 if cost > 0.0 {
2696 info += &format!("\n\nCost\nTotal: {:.4}", cost);
2697 }
2698
2699 if let Some(ref asession) = app.session
2701 && let Some(file_path) = asession.session().session_file().as_ref()
2702 && let Some(h) = crate::agent::session::read_session_header(file_path)
2703 && let Some(ref parent) = h.parent_session
2704 {
2705 info += &format!("\n\nParent: {}", parent);
2706 }
2707
2708 chat_info(app, info.clone());
2709 }
2710 CommandResult::OpenSessionSelector => {
2711 use crate::agent::SessionRepo;
2713 let repo = crate::agent::DefaultSessionRepo::new();
2714 let sessions = repo.list_all(None);
2715
2716 if sessions.is_empty() {
2717 let msg = "No sessions found.".to_string();
2718 chat_info(app, msg.clone());
2719 } else {
2720 let mut info = format!("Available Sessions ({} total)\n\n", sessions.len());
2721 for (i, s) in sessions.iter().take(20).enumerate() {
2722 let name = s.name.as_deref().unwrap_or("unnamed");
2723 let cwd_short = s.cwd.rsplit('/').next().unwrap_or(&s.cwd);
2724 info += &format!(
2725 "{}. {} [{}] {} msgs\n {}\n\n",
2726 i + 1,
2727 name,
2728 fmt_time_short(&s.created),
2729 s.message_count,
2730 cwd_short,
2731 );
2732 }
2733 if sessions.len() > 20 {
2734 info += &format!("... and {} more sessions\n", sessions.len() - 20);
2735 }
2736 info += "Use /resume to open the interactive picker";
2737
2738 chat_info(app, info.clone());
2739 }
2740 }
2741 CommandResult::SessionNamed { name } => {
2742 if let Some(ref mut s) = app.session {
2744 s.session_mut().append_session_info(&name);
2745 }
2746
2747 let stored_name = app
2749 .session
2750 .as_ref()
2751 .and_then(|s| s.session().session_name());
2752 if let Some(ref stored) = stored_name
2753 && stored != &name
2754 {
2755 chat_info(
2756 app,
2757 format!("Session name normalized from {:?} to {:?}", name, stored),
2758 );
2759 }
2760
2761 chat_info(
2762 app,
2763 format!(
2764 "Session name set: {}",
2765 stored_name.as_deref().unwrap_or(&name)
2766 ),
2767 );
2768
2769 app.status_text = Some(format!(
2770 "Session name set: {}",
2771 stored_name.as_deref().unwrap_or(&name)
2772 ));
2773
2774 app.update_session_info();
2776 if let Some(ref s) = app.session {
2777 app.footer.borrow_mut().refresh_from_session(s.session());
2778 }
2779 }
2780 CommandResult::OpenModelSelector => {
2781 app.pending_command_result = Some(result);
2783 }
2784 CommandResult::OpenSettings => {
2785 app.pending_command_result = Some(result);
2787 }
2788 CommandResult::ScopedModels => {
2789 app.pending_command_result = Some(result);
2791 }
2792 CommandResult::ExportSession { path } => {
2793 let msg = if let Some(p) = path {
2794 format!("Export session to {} - not yet implemented.", p)
2795 } else {
2796 "Export session - not yet implemented (defaults to HTML).".to_string()
2797 };
2798 chat_info(app, msg.clone());
2799 }
2800 CommandResult::ImportSession { path } => {
2801 let msg = format!("Import session from {} - not yet implemented.", path);
2802 chat_info(app, msg.clone());
2803 }
2804 CommandResult::ShareSession => {
2805 let msg = "Share session - not yet implemented.".to_string();
2806 chat_info(app, msg.clone());
2807 }
2808 CommandResult::CopyLastMessage => {
2809 let text = app.session.as_ref().and_then(|s| {
2811 let entries = s.session().get_entries();
2812 entries.iter().rev().find_map(|entry| {
2813 if let SessionEntry::Message(m) = entry
2814 && matches!(
2815 &m.message,
2816 yoagent::types::AgentMessage::Llm(
2817 yoagent::types::Message::Assistant {
2818 stop_reason, ..
2819 },
2820 ) if *stop_reason != yoagent::types::StopReason::Aborted
2821 || !crate::agent::types::message_text(&m.message)
2822 .trim()
2823 .is_empty()
2824 )
2825 {
2826 let text = crate::agent::types::message_text(&m.message);
2827 let trimmed = text.trim();
2828 if !trimmed.is_empty() {
2829 return Some(trimmed.to_string());
2830 }
2831 }
2832 None
2833 })
2834 });
2835
2836 let text = match text {
2837 Some(t) => t,
2838 None => {
2839 chat_info(app, "No agent messages to copy yet.");
2840 return;
2841 }
2842 };
2843
2844 copy_to_clipboard(&text);
2846 chat_info(app, "Copied last agent message to clipboard");
2847 }
2848 CommandResult::ShowChangelog => {
2849 let msg = "Changelog - not yet implemented.".to_string();
2850 chat_info(app, msg.clone());
2851 }
2852 CommandResult::ForkSession { message_id } => {
2853 let source_path = app
2855 .session
2856 .as_ref()
2857 .and_then(|s| s.session().session_file());
2858 let session_dir = app.session.as_ref().map(|s| s.session_dir().to_path_buf());
2859 let cwd = app.cwd.clone();
2860
2861 match (source_path, session_dir) {
2862 (Some(ref source), Some(ref target_dir)) => {
2863 match crate::agent::session::fork_session(
2864 source,
2865 target_dir,
2866 message_id.as_deref(),
2867 None,
2868 ) {
2869 Ok(new_id) => {
2870 let dir_entries = std::fs::read_dir(target_dir).ok();
2872 let new_path = dir_entries.and_then(|entries| {
2873 entries
2874 .flatten()
2875 .find(|e| {
2876 let filename = e.file_name();
2877 filename.to_string_lossy().contains(&new_id)
2878 })
2879 .map(|e| e.path())
2880 });
2881
2882 match new_path {
2883 Some(ref path) => {
2884 let new_session =
2886 crate::agent::AgentSession::open(path, None, Some(&cwd));
2887 app.switch_to_session(new_session);
2888
2889 let styled = app.theme.fg(
2890 "accent",
2891 &format!("✓ Forked session: {}", path.display()),
2892 );
2893 chat_add(
2894 app,
2895 std::boxed::Box::new(Text::new(styled, 1, 1, None)),
2896 );
2897 }
2898 None => {
2899 let msg =
2900 format!("Fork created but new file not found: {}", new_id);
2901 chat_info(app, msg);
2902 }
2903 }
2904 }
2905 Err(e) => {
2906 let msg = format!("Fork failed: {}", e);
2907 chat_info(app, msg.clone());
2908 }
2909 }
2910 }
2911 _ => {
2912 let msg = "No active session to fork".to_string();
2913 chat_info(app, msg.clone());
2914 }
2915 }
2916 }
2917 CommandResult::CloneSession => {
2918 let msg = "Clone session - not yet implemented.".to_string();
2919 chat_info(app, msg.clone());
2920 }
2921 CommandResult::SessionTree => {
2922 let msg = "Session tree - not yet implemented.".to_string();
2923 chat_info(app, msg.clone());
2924 }
2925 CommandResult::TrustDecision { decision } => {
2926 let msg = format!("Trust decision '{}' saved.", decision);
2927 chat_info(app, msg.clone());
2928 }
2929 CommandResult::Login {
2930 ref provider,
2931 ref api_key,
2932 } => {
2933 if let (Some(provider), Some(key)) = (provider, api_key) {
2934 handle_login(app, provider, Some(key));
2935 } else {
2936 app.pending_command_result = Some(result);
2938 }
2939 }
2940 CommandResult::Logout { ref provider } => {
2941 if let Some(p) = provider {
2942 handle_logout(app, Some(p));
2943 } else {
2944 app.pending_command_result = Some(result);
2946 }
2947 }
2948 CommandResult::CompactSession(custom_instructions) => {
2949 if app.is_streaming {
2951 interrupt_streaming(app);
2952 }
2953 app.pending_compact = Some(custom_instructions);
2954 }
2955 }
2956}
2957
2958fn find_tool_renderer(
2960 extensions: &[Box<dyn crate::agent::extension::Extension>],
2961 name: &str,
2962) -> Option<Arc<dyn ToolRenderer>> {
2963 for ext in extensions {
2964 for tool in ext.tools() {
2965 if tool.name() == name {
2966 return tool.renderer;
2967 }
2968 }
2969 }
2970 None
2971}
2972
2973fn handle_bang_command(app: &mut App, command: String) {
2977 let cwd = app.cwd.clone();
2978 let tx = app.event_tx.clone();
2979 use yoagent::types::{AgentEvent as YoEvent, Content as YoContent, ToolResult as YoResult};
2980
2981 let renderer = find_tool_renderer(&app.extensions, "bash");
2982 let mut tool = crate::agent::ui::components::ToolExecComponent::new(
2983 "bash",
2984 renderer,
2985 serde_json::json!({"command": command}),
2986 app.cwd.to_string_lossy().to_string(),
2987 "__bang__".to_string(),
2988 );
2989 tool.set_started_at(std::time::Instant::now());
2990 let (invalidate_tx, invalidate_rx) =
2991 crate::agent::ui::components::ToolExecComponent::make_invalidation_channel();
2992 app.invalidate_rxs.push(invalidate_rx);
2993 tool.set_invalidate_tx(invalidate_tx);
2994 tool.set_expanded(app.tools_expanded);
2995 let tool = Rc::new(RefCell::new(tool));
2996 app.pending_tools
2997 .insert("__bang__".to_string(), Rc::downgrade(&tool));
2998 chat_add(
2999 app,
3000 std::boxed::Box::new(crate::agent::ui::components::RcToolExec(tool)),
3001 );
3002 app.is_streaming = true;
3003 app.working.start();
3004 app.footer.borrow_mut().set_streaming(true);
3005 app.pending_tool_executions += 1;
3006
3007 let handle = tokio::spawn(async move {
3008 struct Guard<'a> {
3009 tx: &'a mpsc::UnboundedSender<yoagent::types::AgentEvent>,
3010 sent: bool,
3011 }
3012 impl Drop for Guard<'_> {
3013 fn drop(&mut self) {
3014 if !self.sent {
3015 let _ = self.tx.send(YoEvent::AgentEnd { messages: vec![] });
3016 }
3017 }
3018 }
3019 let mut guard = Guard {
3020 tx: &tx,
3021 sent: false,
3022 };
3023
3024 let send_progress = |text: &str| {
3025 let _ = tx.send(YoEvent::ProgressMessage {
3026 tool_call_id: "__bang__".to_string(),
3027 tool_name: "bash".into(),
3028 text: text.to_string(),
3029 });
3030 };
3031
3032 let mut child = match tokio::process::Command::new("sh")
3033 .arg("-c")
3034 .arg(&command)
3035 .current_dir(&cwd)
3036 .stdout(std::process::Stdio::piped())
3037 .stderr(std::process::Stdio::piped())
3038 .spawn()
3039 {
3040 Ok(c) => c,
3041 Err(e) => {
3042 let _ = tx.send(YoEvent::ToolExecutionEnd {
3043 tool_call_id: "__bang__".to_string(),
3044 tool_name: "bash".into(),
3045 result: YoResult {
3046 content: vec![YoContent::Text {
3047 text: format!("Failed to execute: {:#}", e),
3048 }],
3049 details: serde_json::Value::Null,
3050 },
3051 is_error: true,
3052 });
3053 guard.sent = true;
3054 let _ = tx.send(YoEvent::AgentEnd { messages: vec![] });
3055 return;
3056 }
3057 };
3058
3059 let mut all_output = String::new();
3060 use tokio::io::AsyncReadExt;
3062 let mut stdio = child.stdout.take().unwrap();
3063 let mut stderr = child.stderr.take().unwrap();
3064 let mut buf1 = [0u8; 4096];
3065 let mut buf2 = [0u8; 4096];
3066 let mut stdout_done = false;
3067 let mut stderr_done = false;
3068
3069 loop {
3070 tokio::select! {
3071 result = stdio.read(&mut buf1), if !stdout_done => {
3072 match result {
3073 Ok(0) => stdout_done = true,
3074 Ok(n) => {
3075 if let Ok(text) = std::str::from_utf8(&buf1[..n]) {
3076 all_output.push_str(text);
3077 send_progress(text);
3078 }
3079 }
3080 Err(_) => stdout_done = true,
3081 }
3082 }
3083 result = stderr.read(&mut buf2), if !stderr_done => {
3084 match result {
3085 Ok(0) => stderr_done = true,
3086 Ok(n) => {
3087 if let Ok(text) = std::str::from_utf8(&buf2[..n]) {
3088 all_output.push_str(text);
3089 send_progress(text);
3090 }
3091 }
3092 Err(_) => stderr_done = true,
3093 }
3094 }
3095 }
3096 if stdout_done && stderr_done {
3097 break;
3098 }
3099 }
3100
3101 let status = child.wait().await;
3103 let is_error = match &status {
3104 Ok(s) => !s.success(),
3105 Err(_) => true,
3106 };
3107 let result = if all_output.trim().is_empty() {
3108 "(no output)".to_string()
3109 } else {
3110 all_output.trim().to_string()
3111 };
3112
3113 let _ = tx.send(YoEvent::ToolExecutionEnd {
3114 tool_call_id: "__bang__".to_string(),
3115 tool_name: "bash".into(),
3116 result: YoResult {
3117 content: vec![YoContent::Text { text: result }],
3118 details: serde_json::Value::Null,
3119 },
3120 is_error,
3121 });
3122 guard.sent = true;
3123 let _ = tx.send(YoEvent::AgentEnd { messages: vec![] });
3124 });
3125 app.bash_abort_handle = Some(handle.abort_handle());
3126}
3127
3128pub fn rebuild_chat_from_messages(
3132 chat: &mut crate::tui::Container,
3133 messages: &[yoagent::types::AgentMessage],
3134 cwd: &str,
3135 hide_thinking: bool,
3136 _collapse_tool_output: bool,
3137 extensions: &[Box<dyn crate::agent::extension::Extension>],
3138) {
3139 chat.clear();
3140 use std::collections::HashMap;
3141 let mut pending_tool_components: HashMap<
3142 String,
3143 Rc<RefCell<crate::agent::ui::components::ToolExecComponent>>,
3144 > = HashMap::new();
3145
3146 for msg in messages {
3147 if crate::agent::types::message_is_user(msg) {
3148 let text = crate::agent::types::message_text(msg);
3149 if text.is_empty() {
3150 continue;
3151 }
3152 if !chat.children().is_empty() {
3153 chat.add_child(std::boxed::Box::new(Spacer::new(1)));
3154 }
3155 chat.add_child(std::boxed::Box::new(
3156 crate::agent::ui::components::UserMessageComponent::new(text),
3157 ));
3158 } else if crate::agent::types::message_is_assistant(msg) {
3159 let text = crate::agent::types::message_text(msg);
3160 if let yoagent::types::AgentMessage::Llm(yoagent::types::Message::Assistant {
3161 content,
3162 ..
3163 }) = msg
3164 {
3165 let tcs = crate::agent::types::content_tool_calls(content);
3166 if !tcs.is_empty() {
3167 if !text.trim().is_empty() {
3169 add_assistant_message(chat, &text, hide_thinking);
3170 }
3171 for (id, name, args) in &tcs {
3173 let renderer = find_tool_renderer(extensions, name);
3174 let tool = crate::agent::ui::components::ToolExecComponent::new(
3175 name,
3176 renderer,
3177 args.clone(),
3178 cwd.to_string(),
3179 id.clone(),
3180 );
3181 let tool = Rc::new(RefCell::new(tool));
3182 chat.add_child(std::boxed::Box::new(
3183 crate::agent::ui::components::RcToolExec(tool.clone()),
3184 ));
3185 pending_tool_components.insert(id.clone(), tool);
3186 }
3187 } else if !text.trim().is_empty() {
3188 add_assistant_message(chat, &text, hide_thinking);
3190 }
3191 }
3192 } else if crate::agent::types::message_is_tool_result(msg) {
3193 let is_error = crate::agent::types::message_is_error(msg);
3194 let text = crate::agent::types::message_text(msg);
3195 if let Some(tc_id) = crate::agent::types::message_tool_call_id(msg)
3196 && let Some(tool) = pending_tool_components.remove(tc_id)
3197 {
3198 let clean = text
3199 .strip_prefix("✓ ")
3200 .or_else(|| text.strip_prefix("✗ "))
3201 .unwrap_or(&text);
3202 let mut tool = tool.borrow_mut();
3203 tool.set_result_with_details(clean, is_error, None);
3204 }
3205 } else if crate::agent::types::message_is_extension(msg) {
3206 if let Some(text) = crate::agent::types::message_extension_text(msg) {
3208 if !chat.children().is_empty() {
3209 chat.add_child(std::boxed::Box::new(Spacer::new(1)));
3210 }
3211 chat.add_child(std::boxed::Box::new(InfoMessageComponent::new(text)));
3212 }
3213 }
3214 }
3215}
3216
3217pub fn chat_add(app: &mut App, component: std::boxed::Box<dyn Component>) {
3221 let mut chat = app.chat_container.borrow_mut();
3222 if !chat.children().is_empty() {
3223 chat.add_child(std::boxed::Box::new(Spacer::new(1)));
3224 }
3225 chat.add_child(component);
3226}
3227
3228pub fn chat_info(app: &mut App, msg: impl Into<String>) {
3230 chat_add(
3231 app,
3232 std::boxed::Box::new(InfoMessageComponent::new(msg.into())),
3233 );
3234}
3235
3236fn add_assistant_message(chat: &mut crate::tui::Container, text: &str, hide_thinking: bool) {
3238 if !chat.children().is_empty() {
3239 chat.add_child(std::boxed::Box::new(Spacer::new(1)));
3240 }
3241 let mut asst = crate::agent::ui::components::AssistantMessageComponent::new(text);
3242 if hide_thinking {
3243 asst.set_hide_thinking(true);
3244 }
3245 chat.add_child(std::boxed::Box::new(asst));
3246}
3247
3248fn show_status(app: &mut App, message: String) {
3255 let mut chat = app.chat_container.borrow_mut();
3256 if let Some(prev_len) = app.last_status_len
3258 && chat.len() == prev_len
3259 && prev_len >= 2
3260 {
3261 chat.pop_child(); chat.pop_child(); }
3264 app.last_status_len = None;
3265 drop(chat);
3266
3267 let mut chat = app.chat_container.borrow_mut();
3269 if !chat.children().is_empty() {
3270 chat.add_child(std::boxed::Box::new(Spacer::new(1)));
3271 }
3272 chat.add_child(std::boxed::Box::new(InfoMessageComponent::new(message)));
3273 app.last_status_len = Some(chat.len());
3274}
3275
3276fn extract_text_content(content: &[yoagent::types::Content]) -> String {
3278 content
3279 .iter()
3280 .filter_map(|c| {
3281 if let yoagent::types::Content::Text { text } = c {
3282 Some(text.clone())
3283 } else {
3284 None
3285 }
3286 })
3287 .collect::<Vec<_>>()
3288 .join("")
3289}
3290
3291fn copy_to_clipboard(text: &str) -> bool {
3296 use std::io::Write;
3297 let mut copied = false;
3298
3299 if !copied
3301 && std::process::Command::new("pbcopy")
3302 .stdin(std::process::Stdio::piped())
3303 .stdout(std::process::Stdio::null())
3304 .stderr(std::process::Stdio::null())
3305 .spawn()
3306 .ok()
3307 .and_then(|mut child| {
3308 let _ = child.stdin.take().map(|mut stdin| {
3309 let _ = stdin.write_all(text.as_bytes());
3310 });
3311 child.wait().ok()
3312 })
3313 .is_some_and(|s| s.success())
3314 {
3315 copied = true;
3316 }
3317
3318 if !copied
3320 && std::process::Command::new("clip")
3321 .stdin(std::process::Stdio::piped())
3322 .stdout(std::process::Stdio::null())
3323 .stderr(std::process::Stdio::null())
3324 .spawn()
3325 .ok()
3326 .and_then(|mut child| {
3327 let _ = child.stdin.take().map(|mut stdin| {
3328 let _ = stdin.write_all(text.as_bytes());
3329 });
3330 child.wait().ok()
3331 })
3332 .is_some_and(|s| s.success())
3333 {
3334 copied = true;
3335 }
3336
3337 if !copied
3339 && std::env::var("TERMUX_VERSION").is_ok()
3340 && let Ok(mut child) = std::process::Command::new("termux-clipboard-set")
3341 .stdin(std::process::Stdio::piped())
3342 .stdout(std::process::Stdio::null())
3343 .stderr(std::process::Stdio::null())
3344 .spawn()
3345 {
3346 let _ = child.stdin.take().map(|mut stdin| {
3347 let _ = stdin.write_all(text.as_bytes());
3348 });
3349 copied = child.wait().ok().is_some_and(|s| s.success());
3350 }
3351
3352 if !copied
3354 && std::env::var("WAYLAND_DISPLAY").is_ok()
3355 && std::process::Command::new("which")
3356 .arg("wl-copy")
3357 .stdout(std::process::Stdio::null())
3358 .stderr(std::process::Stdio::null())
3359 .status()
3360 .ok()
3361 .is_some_and(|s| s.success())
3362 && let Ok(mut child) = std::process::Command::new("wl-copy")
3363 .stdin(std::process::Stdio::piped())
3364 .stdout(std::process::Stdio::null())
3365 .stderr(std::process::Stdio::null())
3366 .spawn()
3367 {
3368 let _ = child.stdin.take().map(|mut stdin| {
3369 let _ = stdin.write_all(text.as_bytes());
3370 });
3371 copied = true;
3373 }
3374
3375 if !copied
3377 && std::process::Command::new("xclip")
3378 .arg("-selection")
3379 .arg("clipboard")
3380 .arg("-i")
3381 .stdin(std::process::Stdio::piped())
3382 .stdout(std::process::Stdio::null())
3383 .stderr(std::process::Stdio::null())
3384 .spawn()
3385 .ok()
3386 .and_then(|mut child| {
3387 let _ = child.stdin.take().map(|mut stdin| {
3388 let _ = stdin.write_all(text.as_bytes());
3389 });
3390 child.wait().ok()
3391 })
3392 .is_some_and(|s| s.success())
3393 {
3394 copied = true;
3395 }
3396
3397 if !copied
3398 && std::process::Command::new("xsel")
3399 .arg("--clipboard")
3400 .arg("--input")
3401 .stdin(std::process::Stdio::piped())
3402 .stdout(std::process::Stdio::null())
3403 .stderr(std::process::Stdio::null())
3404 .spawn()
3405 .ok()
3406 .and_then(|mut child| {
3407 let _ = child.stdin.take().map(|mut stdin| {
3408 let _ = stdin.write_all(text.as_bytes());
3409 });
3410 child.wait().ok()
3411 })
3412 .is_some_and(|s| s.success())
3413 {
3414 copied = true;
3415 }
3416
3417 let remote = std::env::var("SSH_CONNECTION").is_ok()
3419 || std::env::var("SSH_CLIENT").is_ok()
3420 || std::env::var("MOSH_CONNECTION").is_ok();
3421
3422 if remote || !copied {
3423 use base64::Engine as _;
3424 let encoded = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
3425 if encoded.len() <= 100_000 {
3427 let _ = writeln!(std::io::stdout(), "\x1b]52;c;{}\x07", encoded);
3428 let _ = std::io::stdout().flush();
3429 copied = true;
3430 }
3431 }
3432
3433 copied
3434}
3435
3436fn handle_agent_event(app: &mut App, event: yoagent::types::AgentEvent) {
3443 {
3446 let ev = &event;
3447 if let E::MessageEnd { message } = ev {
3448 if crate::agent::types::message_is_user(message)
3449 && let Some(ref mut s) = app.session
3450 {
3451 s.reset_overflow_recovery();
3452 }
3453 if crate::agent::types::message_error(message).is_none()
3454 && !crate::agent::types::message_is_system_stop(message)
3455 && let Some(ref mut s) = app.session
3456 {
3457 s.on_agent_event(ev);
3458 }
3459 }
3460 if let E::ToolExecutionEnd { tool_call_id, .. } = ev
3461 && tool_call_id != "__bang__"
3462 && let Some(ref mut s) = app.session
3463 {
3464 s.on_agent_event(ev);
3465 }
3466 if let E::AgentEnd { .. } = ev
3467 && let Some(ref mut s) = app.session
3468 {
3469 s.on_agent_event(ev);
3470 }
3471 }
3472
3473 use yoagent::types::AgentEvent as E;
3475 match event {
3476 E::AgentStart => {
3477 app.is_streaming = true;
3478 app.working.start();
3479 app.refresh_git_branch();
3480 }
3481 E::TurnStart => {}
3482 E::MessageStart { message } => {
3483 if crate::agent::types::message_is_user(&message) {
3487 let text = crate::agent::types::message_text(&message);
3488 if !text.is_empty() {
3489 chat_add(
3490 app,
3491 std::boxed::Box::new(
3492 crate::agent::ui::components::UserMessageComponent::new(&text),
3493 ),
3494 );
3495 }
3496 }
3497 }
3498 E::MessageUpdate { delta, .. } => {
3499 use yoagent::types::StreamDelta;
3500 match delta {
3501 StreamDelta::Text { delta } => {
3502 if let Some(weak) = app.streaming_component.as_ref().and_then(|w| w.upgrade()) {
3503 weak.borrow_mut().append_text(&delta);
3504 } else {
3505 use crate::tui::components::rc_ref_cell_component::RcRefCellComponent;
3506 let comp = Rc::new(RefCell::new(
3507 crate::agent::ui::components::AssistantMessageComponent::new(&delta),
3508 ));
3509 if app.hide_thinking {
3510 comp.borrow_mut().set_hide_thinking(true);
3511 }
3512 app.streaming_component = Some(Rc::downgrade(&comp));
3513 app.chat_container
3514 .borrow_mut()
3515 .add_child(std::boxed::Box::new(RcRefCellComponent(comp)));
3516 }
3517 }
3518 StreamDelta::Thinking { delta } => {
3519 if let Some(weak) = app.streaming_component.as_ref().and_then(|w| w.upgrade()) {
3520 weak.borrow_mut()
3521 .add_thinking(&delta, app.thinking_level.clone());
3522 } else {
3523 use crate::tui::components::rc_ref_cell_component::RcRefCellComponent;
3524 let mut comp =
3525 crate::agent::ui::components::AssistantMessageComponent::new("");
3526 comp.add_thinking(&delta, app.thinking_level.clone());
3527 if app.hide_thinking {
3528 comp.set_hide_thinking(true);
3529 }
3530 let comp = Rc::new(RefCell::new(comp));
3531 app.streaming_component = Some(Rc::downgrade(&comp));
3532 app.chat_container
3533 .borrow_mut()
3534 .add_child(std::boxed::Box::new(RcRefCellComponent(comp)));
3535 }
3536 }
3537 StreamDelta::ToolCallDelta { .. } => {}
3538 }
3539 }
3540 E::ToolExecutionStart {
3541 tool_call_id,
3542 tool_name,
3543 args,
3544 } => {
3545 app.pending_tool_executions += 1;
3546 app.streaming_component = None;
3547 let name = tool_name;
3548 let renderer = find_tool_renderer(&app.extensions, &name);
3549 let started_at = std::time::Instant::now();
3550 let (invalidate_tx, invalidate_rx) =
3551 crate::agent::ui::components::ToolExecComponent::make_invalidation_channel();
3552 app.invalidate_rxs.push(invalidate_rx);
3553 let comp: Rc<RefCell<_>> = {
3554 let mut tool = crate::agent::ui::components::ToolExecComponent::new(
3555 &name,
3556 renderer,
3557 args.clone(),
3558 app.cwd.to_string_lossy().to_string(),
3559 tool_call_id.clone(),
3560 );
3561 tool.set_started_at(std::time::Instant::now());
3562 tool.set_invalidate_tx(invalidate_tx);
3563 Rc::new(RefCell::new(tool))
3564 };
3565 comp.borrow_mut().set_expanded(app.tools_expanded);
3566 app.pending_tools
3567 .insert(tool_call_id.clone(), Rc::downgrade(&comp));
3568 app.tool_call_start_times
3569 .insert(tool_call_id.clone(), started_at);
3570 chat_add(
3571 app,
3572 std::boxed::Box::new(crate::agent::ui::components::RcToolExec(comp)),
3573 );
3574 }
3575 E::ToolExecutionUpdate {
3576 tool_call_id,
3577 partial_result,
3578 ..
3579 } => {
3580 let partial_text = extract_text_content(&partial_result.content);
3582 if !partial_text.is_empty()
3583 && let Some(weak) = app.pending_tools.get(&tool_call_id)
3584 && let Some(comp) = weak.upgrade()
3585 {
3586 comp.borrow_mut().append_output(&partial_text);
3587 }
3588 }
3589 E::ToolExecutionEnd {
3590 tool_call_id,
3591 tool_name: _,
3592 result,
3593 is_error,
3594 } => {
3595 app.pending_tool_executions = app.pending_tool_executions.saturating_sub(1);
3596 let content = extract_text_content(&result.content);
3597 if let Some(weak) = app.pending_tools.get(&tool_call_id)
3598 && let Some(comp) = weak.upgrade()
3599 {
3600 comp.borrow_mut()
3601 .set_result_with_details(&content, is_error, Some(result.details));
3602 app.tool_call_start_times.remove(&tool_call_id);
3603 }
3604 }
3605 E::ProgressMessage {
3606 text, tool_name, ..
3607 } => {
3608 if let Some(weak) = app.pending_tools.get("__bang__")
3610 && let Some(comp) = weak.upgrade()
3611 {
3612 comp.borrow_mut().append_output(&text);
3613 } else if tool_name.is_empty() {
3614 app.status_text = Some(text.trim().to_string());
3616 }
3617 }
3618 E::TurnEnd { message, .. } => {
3619 app.streaming_component = None;
3620 if let Some(err) = crate::agent::types::message_error(&message) {
3622 chat_info(app, format!("Provider error: {}", err));
3623 }
3624 }
3625 E::AgentEnd { messages } => {
3626 app.streaming_component = None;
3627 app.is_streaming = false;
3628 app.working.stop();
3629 app.footer.borrow_mut().set_streaming(false);
3630 if let Some(ref s) = app.session {
3632 app.footer.borrow_mut().refresh_from_session(s.session());
3633 }
3634 app.pending_auto_compact = app.auto_compact;
3637 for msg in messages.iter().rev() {
3643 if let Some(yoagent::types::Message::Assistant {
3644 content,
3645 stop_reason,
3646 error_message,
3647 ..
3648 }) = msg.as_llm()
3649 && stop_reason != &yoagent::types::StopReason::ToolUse
3650 {
3651 if let Some(err) = error_message {
3652 chat_info(app, format!("Provider error: {}", err));
3653 break;
3654 }
3655 let has_visible = content.iter().any(|c| match c {
3659 yoagent::types::Content::Text { text } => !text.trim().is_empty(),
3660 yoagent::types::Content::ToolCall { .. } => true,
3661 _ => false,
3662 });
3663 if !has_visible {
3664 chat_info(
3665 app,
3666 "The agent returned an empty response. \
3667 This can happen when the provider's context \
3668 limit is exceeded or the model declined to \
3669 respond. Try sending a new message."
3670 .to_string(),
3671 );
3672 break;
3673 }
3674 }
3675 }
3676 }
3677 E::MessageEnd { message } => {
3678 if let Some(err) = crate::agent::types::message_error(&message) {
3681 chat_info(app, err.to_string());
3682 let ext = crate::agent::types::extension_message("error", err, true);
3683 if let Some(ref mut s) = app.session {
3684 s.persist_extension_message(&ext);
3685 }
3686 } else if crate::agent::types::message_is_system_stop(&message) {
3687 let text = crate::agent::types::message_text(&message);
3688 chat_info(app, text.clone());
3689 if let Some(ref mut s) = app.session {
3690 let ext = crate::agent::types::extension_message("system_stop", text, true);
3691 s.persist_extension_message(&ext);
3692 }
3693 } else if crate::agent::types::message_is_extension(&message) {
3694 if let Some(text) = crate::agent::types::message_extension_text(&message) {
3696 chat_info(app, text);
3697 }
3698 }
3699 }
3700 E::InputRejected { reason } => {
3701 let msg = format!("Input rejected: {}", reason);
3702 chat_info(app, msg);
3703 }
3704 }
3705}
3706
3707fn parse_bang_command(input: &str) -> Option<(String, bool)> {
3709 if let Some(rest) = input.strip_prefix("!!") {
3710 let cmd = rest.trim();
3711 if cmd.is_empty() {
3712 None
3713 } else {
3714 Some((cmd.to_string(), true))
3715 }
3716 } else if let Some(rest) = input.strip_prefix('!') {
3717 let cmd = rest.trim();
3718 if cmd.is_empty() {
3719 None
3720 } else {
3721 Some((cmd.to_string(), false))
3722 }
3723 } else {
3724 None
3725 }
3726}
3727
3728fn format_number(n: u64) -> String {
3730 let s = n.to_string();
3731 let mut result = String::new();
3732 for (i, c) in s.chars().rev().enumerate() {
3733 if i > 0 && i % 3 == 0 {
3734 result.push(',');
3735 }
3736 result.push(c);
3737 }
3738 result.chars().rev().collect()
3739}
3740
3741fn fmt_time_short(dt: &chrono::DateTime<chrono::Utc>) -> String {
3743 dt.format("%Y-%m-%d %H:%M").to_string()
3744}
3745
3746fn xml_escape(s: &str) -> String {
3749 s.replace('&', "&")
3750 .replace('<', "<")
3751 .replace('>', ">")
3752 .replace('"', """)
3753 .replace('\'', "'")
3754}
3755
3756fn strip_frontmatter(content: &str) -> String {
3757 let content = content.trim_start();
3758 if !content.starts_with("---") {
3759 return content.to_string();
3760 }
3761 let remaining = &content[3..];
3762 let end = match remaining.find("---") {
3763 Some(pos) => pos,
3764 None => return content.to_string(),
3765 };
3766 let body_start = 3 + end + 3;
3767 content[body_start..].trim().to_string()
3768}
3769
3770fn read_skill_body(file_path: &std::path::Path) -> Option<String> {
3771 let content = std::fs::read_to_string(file_path).ok()?;
3772 Some(strip_frontmatter(&content))
3773}
3774
3775fn format_skill_invocation(skill: &yoagent::skills::Skill, extra: Option<&str>) -> String {
3776 let body = read_skill_body(&skill.file_path).unwrap_or_default();
3777 let base = skill.base_dir.to_string_lossy();
3778 let block = format!(
3779 r#"<skill name="{}" location="{}">
3780References are relative to {}.
3781
3782{}
3783</skill>"#,
3784 xml_escape(&skill.name),
3785 xml_escape(&skill.file_path.to_string_lossy()),
3786 base,
3787 body
3788 );
3789 match extra {
3790 Some(instr) if !instr.is_empty() => format!("{}\n\n{}", block, instr),
3791 _ => block,
3792 }
3793}
3794
3795fn expand_skill_command(text: &str, skills: &[yoagent::skills::Skill]) -> String {
3796 if !text.starts_with("/skill:") {
3797 return text.to_string();
3798 }
3799 let rest = &text[7..];
3800 let (skill_name, args) = match rest.find(' ') {
3801 Some(pos) => (&rest[..pos], rest[pos + 1..].trim()),
3802 None => (rest, ""),
3803 };
3804 match skills.iter().find(|s| s.name == skill_name) {
3805 Some(s) => format_skill_invocation(s, if args.is_empty() { None } else { Some(args) }),
3806 None => text.to_string(),
3807 }
3808}