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;
15
16use crate::agent::ui::chat_editor::{ChatEditor, InputAction};
17use crate::agent::ui::components::EditorComponent;
18use crate::agent::ui::components::FooterComponent;
19use crate::agent::ui::components::InfoMessageComponent;
20use crate::agent::ui::footer::Footer;
21use crate::agent::ui::model_selector::ModelSelector;
22use crate::agent::ui::theme::RabTheme;
23use crate::agent::ui::working::WorkingIndicator;
24use crate::builtin::commands::SessionInfoInternal;
25use crate::tui::Component;
26use crate::tui::TUI;
27use crate::tui::focusable::Focusable;
28
29use crate::agent::ui::theme::ThemeKey;
30use crate::tui::components::Spacer;
31use crate::tui::components::Text;
32use crate::tui::terminal::{self, ProcessTerminal, TerminalTrait};
33use crossterm::event::KeyEvent;
34use tokio::sync::mpsc;
35
36const THINKING_LEVELS: &[&str] = &["xhigh", "high", "medium", "low", "off"];
40
41pub struct AppConfig {
43 pub model: String,
44 pub system_prompt: String,
45 pub extensions: Vec<Box<dyn Extension>>,
46 pub cwd: PathBuf,
47 pub thinking_level: Option<String>,
48 pub available_models: Vec<String>,
49 pub hide_thinking: bool,
50 pub collapse_tool_output: bool,
51 pub interactive: bool,
52 pub settings: crate::agent::settings::Settings,
53 pub context_files: Vec<String>,
55
56 pub skills: Vec<yoagent::skills::Skill>,
58 pub model_supports_reasoning: bool,
60 pub session_info: Option<std::sync::Arc<std::sync::Mutex<Option<SessionInfoInternal>>>>,
62 pub api_key: String,
64}
65
66pub struct App {
68 cwd: PathBuf,
69 model: String,
70 thinking_level: Option<String>,
71 system_prompt: String,
72 theme: RabTheme,
73
74 commands: Vec<(String, String)>,
76
77 available_models: Vec<String>,
79
80 pub chat_container: std::rc::Rc<std::cell::RefCell<crate::tui::Container>>,
83
84 pub status_section: std::rc::Rc<std::cell::RefCell<crate::tui::components::DynamicLines>>,
87 pub working_section: std::rc::Rc<std::cell::RefCell<crate::tui::components::DynamicLines>>,
89
90 editor: Rc<RefCell<ChatEditor>>,
92
93 event_tx: mpsc::UnboundedSender<yoagent::types::AgentEvent>,
95 event_rx: mpsc::UnboundedReceiver<yoagent::types::AgentEvent>,
96
97 is_streaming: bool,
99 pending_submit: Option<String>,
101 pending_compact: Option<Option<String>>,
103 pending_auto_compact: bool,
105 agent: Option<yoagent::agent::Agent>,
107 forward_handle: Option<tokio::task::JoinHandle<()>>,
110
111 hide_thinking: bool,
113 collapse_tool_output: bool,
114 tools_expanded: bool,
116
117 scroll_offset: usize,
119
120 last_clear_time: std::time::Instant,
122
123 should_quit: bool,
125
126 pending_tool_executions: usize,
131
132 bash_abort_handle: Option<tokio::task::AbortHandle>,
134
135 session: Option<AgentSession>,
137
138 footer: Rc<RefCell<Footer>>,
140
141 footer_provider: Rc<RefCell<FooterDataProvider>>,
143
144 pending_tools: HashMap<String, Weak<RefCell<crate::agent::ui::components::ToolExecComponent>>>,
147
148 tool_call_start_times: HashMap<String, std::time::Instant>,
151
152 invalidate_rxs: Vec<tokio::sync::mpsc::UnboundedReceiver<()>>,
155
156 streaming_component:
159 Option<Weak<RefCell<crate::agent::ui::components::AssistantMessageComponent>>>,
160
161 working: WorkingIndicator,
163
164 status_text: Option<String>,
166
167 pending_command_result: Option<CommandResult>,
170
171 extensions: Arc<Vec<Box<dyn Extension>>>,
174 skills: Vec<yoagent::skills::Skill>,
176 api_key: String,
178 session_info: Option<std::sync::Arc<std::sync::Mutex<Option<SessionInfoInternal>>>>,
180
181 auto_compact: bool,
183
184 settings: crate::agent::settings::Settings,
186
187 header: Rc<RefCell<crate::agent::ui::components::HeaderComponent>>,
192
193 session_picker: Option<crate::agent::ui::components::SessionPicker>,
195
196 last_status_len: Option<usize>,
201 }
204
205impl App {
206 fn new(config: AppConfig, session: AgentSession) -> Self {
207 let mut agent_session = session;
208 let mut model_config = crate::agent::base_model_config(&config.model);
209 model_config.context_window =
210 crate::agent::compaction::get_model_context_window(&config.model) as u32;
211 agent_session.set_compaction_config(
212 config.api_key.clone(),
213 &config.model,
214 crate::agent::compaction::get_model_context_window(&config.model),
215 Some(model_config),
216 );
217 agent_session.set_auto_compact(config.settings.auto_compact.unwrap_or(true));
218 let (tx, rx) = mpsc::unbounded_channel();
219 use crate::agent::ui::theme::current_theme;
220 let theme = current_theme().clone();
221
222 let mut editor = ChatEditor::new(&theme, config.cwd.clone());
223
224 use crate::tui::autocomplete::AutocompleteItem as AutoAutocompleteItem;
226 use crate::tui::autocomplete::SlashCommand as AutoSlashCommand;
227 let auto_commands: Vec<AutoSlashCommand> = config
228 .extensions
229 .iter()
230 .flat_map(|e| e.commands())
231 .map(|cmd| {
232 let handler = cmd.handler;
233 AutoSlashCommand {
234 name: cmd.name,
235 description: Some(cmd.description),
236 argument_hint: None,
237 argument_completions: None,
238 get_argument_completions: Some(std::sync::Arc::new(
239 move |prefix: &str| -> Vec<AutoAutocompleteItem> {
240 handler
241 .argument_completions(prefix)
242 .into_iter()
243 .map(|item| AutoAutocompleteItem {
244 value: item.value,
245 label: item.label,
246 description: item.description,
247 })
248 .collect()
249 },
250 )),
251 }
252 })
253 .collect();
254 editor.set_slash_commands(auto_commands);
255
256 let commands: Vec<(String, String)> = config
258 .extensions
259 .iter()
260 .flat_map(|e| e.commands())
261 .map(|c| (c.name, c.description))
262 .collect();
263
264 let editor = Rc::new(RefCell::new(editor));
265
266 let footer_provider = Rc::new(RefCell::new(FooterDataProvider::new(config.cwd.clone())));
267
268 let mut footer = Footer::new(
269 config.cwd.to_string_lossy().to_string(),
270 footer_provider.clone(),
271 );
272 footer.set_model(&config.model);
273 footer.set_model_supports_reasoning(config.model_supports_reasoning);
274 footer.set_thinking_level(config.thinking_level.clone());
275 footer.set_context_window(crate::agent::compaction::get_model_context_window(
276 &config.model,
277 ));
278
279 let footer = Rc::new(RefCell::new(footer));
280
281 let context = agent_session.session().build_session_context();
283 let history_messages = context.messages.clone();
284
285 let mut resource_parts: Vec<String> = Vec::new();
287 if !config.context_files.is_empty() {
288 let ctx = config.context_files.join(", ");
289 resource_parts.push(format!("Context: {}", ctx));
290 }
291 if !config.skills.is_empty() {
292 let skill_names: Vec<&str> = config.skills.iter().map(|s| s.name.as_str()).collect();
293 resource_parts.push(format!("Skills: {}", skill_names.join(", ")));
294 }
295
296 let cwd_string = config.cwd.to_string_lossy().to_string();
300 let chat_container =
301 std::rc::Rc::new(std::cell::RefCell::new(crate::tui::Container::new()));
302 {
303 let mut chat = chat_container.borrow_mut();
304
305 if !resource_parts.is_empty() {
307 chat.add_child(std::boxed::Box::new(
308 crate::agent::ui::components::InfoMessageComponent::new(
309 resource_parts.join(" · "),
310 ),
311 ));
312 }
313
314 rebuild_chat_from_messages(
315 &mut chat,
316 &history_messages,
317 &cwd_string,
318 config.hide_thinking,
319 config.collapse_tool_output,
320 &config.extensions,
321 );
322 }
323
324 let result = Self {
325 cwd: config.cwd,
326 model: config.model,
327 thinking_level: config.thinking_level,
328 system_prompt: config.system_prompt,
329 theme,
330 commands,
331 available_models: config.available_models,
332 chat_container,
333 pending_tools: HashMap::new(),
334 tool_call_start_times: HashMap::new(),
335 invalidate_rxs: Vec::new(),
336 streaming_component: None,
337
338 status_section: std::rc::Rc::new(std::cell::RefCell::new(
339 crate::tui::components::DynamicLines::new(),
340 )),
341 working_section: std::rc::Rc::new(std::cell::RefCell::new(
342 crate::tui::components::DynamicLines::new(),
343 )),
344 editor,
345 event_tx: tx,
346 event_rx: rx,
347 is_streaming: false,
348 pending_submit: None,
349 pending_compact: None,
350 pending_auto_compact: false,
351 agent: None,
352 forward_handle: None,
353 pending_command_result: None,
354 hide_thinking: config.hide_thinking,
355 collapse_tool_output: config.collapse_tool_output,
356 tools_expanded: !config.collapse_tool_output,
357 scroll_offset: 0,
358 last_clear_time: std::time::Instant::now(),
359
360 should_quit: false,
361 pending_tool_executions: 0,
362 bash_abort_handle: None,
363 session: Some(agent_session),
364 footer,
365 footer_provider,
366 working: WorkingIndicator::new(),
367 extensions: Arc::new(config.extensions),
368
369 skills: config.skills,
370 session_info: config.session_info,
371 api_key: config.api_key,
372 settings: config.settings,
373 auto_compact: true,
374 status_text: None,
375 header: Rc::new(RefCell::new(
376 crate::agent::ui::components::HeaderComponent::new(),
377 )),
378 session_picker: None,
379 last_status_len: None,
380 };
381
382 result.update_session_info();
384
385 if let Some(ref s) = result.session {
387 result.footer.borrow_mut().refresh_from_session(s.session());
388 }
389
390 result
391 }
392
393 fn update_session_info(&self) {
395 if let Some(ref session) = self.session
396 && let Some(ref info) = self.session_info
397 {
398 let si = crate::builtin::commands::compute_session_info(session.session());
399 if let Ok(mut guard) = info.lock() {
400 *guard = Some(si);
401 }
402 }
403 }
404
405 fn refresh_git_branch(&self) {
408 self.footer_provider.borrow_mut().refresh_git_branch();
409 }
410
411 fn clear_session_state(&mut self) {
413 self.chat_container.borrow_mut().clear();
414 self.streaming_component = None;
415 self.pending_tools.clear();
416 self.tool_call_start_times.clear();
417 self.pending_submit = None;
418 }
419
420 fn rebuild_from_session_context(&mut self) {
423 if let Some(ref agent_session) = self.session {
424 let context = agent_session.session().build_session_context();
425 {
426 let mut chat = self.chat_container.borrow_mut();
427 rebuild_chat_from_messages(
428 &mut chat,
429 &context.messages,
430 &self.cwd.to_string_lossy(),
431 self.hide_thinking,
432 self.collapse_tool_output,
433 &self.extensions,
434 );
435 }
436 if let Some(ref mut agent) = self.agent {
437 agent.replace_messages(context.messages);
438 }
439 }
440 }
441
442 fn switch_to_session(&mut self, new_session: AgentSession) {
444 let ctx = new_session.session().build_session_context();
445 self.clear_session_state();
446 rebuild_chat_from_messages(
447 &mut self.chat_container.borrow_mut(),
448 &ctx.messages,
449 &self.cwd.to_string_lossy(),
450 self.hide_thinking,
451 self.collapse_tool_output,
452 &self.extensions,
453 );
454 self.footer
456 .borrow_mut()
457 .refresh_from_session(new_session.session());
458
459 self.session = Some(new_session);
460 self.agent = None;
461 self.update_session_info();
462 }
463}
464
465pub async fn run(config: AppConfig, session: AgentSession) -> anyhow::Result<()> {
467 crate::agent::ui::theme::init_theme(Some("dark"), false);
469
470 let mut term = ProcessTerminal::new();
471 let mut stdout = std::io::stdout();
472
473 term.start(&mut stdout)?;
477 term.hide_cursor(&mut stdout)?;
478 term.set_color_scheme_notifications(&mut stdout, true)?;
479 crate::tui::terminal::start_stdin_reader();
480
481 let mut tui = TUI::new();
482 tui.set_clear_on_shrink(false);
485 let mut app = App::new(config, session);
486
487 app.editor.borrow_mut().editor.set_focused(true);
489
490 tui.root.add_child(std::boxed::Box::new(
493 crate::tui::components::RcRefCellComponent(
494 app.header.clone() as Rc<RefCell<dyn Component>>,
495 ),
496 ));
497 tui.root.add_child(std::boxed::Box::new(
498 crate::tui::components::RcRefCellComponent(app.chat_container.clone()
499 as std::rc::Rc<std::cell::RefCell<dyn crate::tui::Component>>),
500 ));
501 tui.root.add_child(std::boxed::Box::new(
502 crate::tui::components::RcRefCellComponent(app.status_section.clone()
503 as std::rc::Rc<std::cell::RefCell<dyn crate::tui::Component>>),
504 ));
505 tui.root.add_child(std::boxed::Box::new(
506 crate::tui::components::RcRefCellComponent(app.working_section.clone()
507 as std::rc::Rc<std::cell::RefCell<dyn crate::tui::Component>>),
508 ));
509 tui.root
510 .add_child(std::boxed::Box::new(EditorComponent(app.editor.clone())));
511 tui.root
512 .add_child(std::boxed::Box::new(FooterComponent(app.footer.clone())));
513
514 app.editor.borrow_mut().update_border_color(
516 app.thinking_level.as_deref(),
517 &app.theme as &dyn crate::tui::Theme,
518 );
519
520 let mut cols: u16 = 80;
523 let mut rows: u16 = 24;
524 let mut dirty = true; loop {
527 let mut had_event = false;
532 while let Ok(event) = app.event_rx.try_recv() {
533 handle_agent_event(&mut app, event);
534 had_event = true;
535 }
536 if had_event {
537 dirty = true;
538 }
539
540 loop {
544 match terminal::try_recv_terminal_event() {
545 Some(terminal::TerminalEvent::Key(key)) => {
546 if !tui.route_input(&key) {
548 handle_input(&mut app, &mut tui, &mut term, &key);
549 }
550 }
551 Some(terminal::TerminalEvent::Paste(content)) => {
552 if !tui.route_paste(&content) {
555 app.editor.borrow_mut().editor.handle_paste(&content);
556 }
557 }
558 Some(terminal::TerminalEvent::Resize(w, h)) => {
559 app.editor.borrow_mut().editor.set_terminal_rows(h as usize);
560 tui.set_dimensions(w as usize, h as usize);
561 }
562 None => break,
563 }
564 dirty = true;
565 }
566
567 while let Ok(event) = app.event_rx.try_recv() {
574 handle_agent_event(&mut app, event);
575 dirty = true;
576 }
577
578 if app.forward_handle.as_ref().is_some_and(|h| h.is_finished()) {
584 app.forward_handle.take();
585 if let Some(ref mut agent) = app.agent {
586 agent.finish().await;
588 }
589 }
590
591 if !app.is_streaming
596 && let Some(text) = app.pending_submit.take()
597 {
598 start_agent_loop(&mut app, text).await;
599 dirty = true;
600 }
601
602 if let Some(custom_instructions) = app.pending_compact.take() {
604 handle_compact_command(&mut app, custom_instructions).await;
605 dirty = true;
606 }
607
608 if app.pending_auto_compact {
612 app.pending_auto_compact = false;
613 handle_auto_compact(&mut app).await;
614 dirty = true;
615 }
616
617 if let Some(result) = app.pending_command_result.take() {
619 match result {
620 CommandResult::ShowHelp => {
621 show_help_overlay(&mut app, &mut tui);
622 }
623 CommandResult::OpenSessionSelector => {
624 let mut picker = crate::agent::ui::components::SessionPicker::new();
626 let repo = crate::agent::DefaultSessionRepo::new();
627 picker.load_sessions(&repo);
628 app.session_picker = Some(picker);
629 app.status_text = None;
630 }
631 CommandResult::OpenSettings => {
632 chat_add(
633 &mut app,
634 std::boxed::Box::new(InfoMessageComponent::new(
635 "Settings menu - not yet implemented.",
636 )),
637 );
638 }
639 CommandResult::ScopedModels => {
640 chat_add(
641 &mut app,
642 std::boxed::Box::new(InfoMessageComponent::new(
643 "Scoped models - not yet implemented.",
644 )),
645 );
646 }
647 CommandResult::Login { .. } => {
648 chat_add(
649 &mut app,
650 std::boxed::Box::new(InfoMessageComponent::new(
651 "Login dialog - not yet implemented.",
652 )),
653 );
654 }
655 _ => {}
656 }
657 dirty = true;
658 }
659
660 app.invalidate_rxs.retain_mut(|rx| {
662 if rx.try_recv().is_ok() {
663 dirty = true;
664 true
665 } else {
666 !rx.is_closed()
667 }
668 });
669
670 if dirty && let Ok((w, h)) = term.size() {
673 app.editor.borrow_mut().editor.set_terminal_rows(h as usize);
674 cols = w;
675 rows = h;
676 }
677
678 if app.working.tick() {
680 dirty = true;
681 }
682
683 let mut tools_to_remove: Vec<String> = Vec::new();
685 for (id, weak) in app.pending_tools.iter() {
686 if let Some(comp) = weak.upgrade() {
687 if comp.borrow_mut().tick_timer() {
688 dirty = true;
689 }
690 } else {
691 tools_to_remove.push(id.clone());
692 }
693 }
694 for id in tools_to_remove {
695 app.pending_tools.remove(&id);
696 }
697
698 if dirty {
700 compose_ui(&mut app, cols as usize);
702 tui.set_dimensions(cols as usize, rows as usize);
703 tui.render(cols as usize, rows as usize, &mut stdout)?;
704 dirty = false;
705 }
706
707 tokio::time::sleep(if dirty || app.is_streaming || app.working.should_show() {
711 Duration::from_millis(16)
712 } else {
713 Duration::from_millis(50)
714 })
715 .await;
716
717 app.status_text = None;
719
720 if app.should_quit {
721 break;
722 }
723 }
724
725 tui.finalize(&mut stdout)?;
728 term.set_color_scheme_notifications(&mut stdout, false)?;
729 term.show_cursor(&mut stdout)?;
730 term.stop(&mut stdout)?;
731
732 Ok(())
733}
734
735fn compose_ui(app: &mut App, width: usize) {
741 if let Some(ref picker) = app.session_picker {
743 let (_lines, _cursor_y) = picker.render(width, &app.theme as &dyn crate::tui::Theme);
744 app.chat_container.borrow_mut().clear();
746 app.status_section.borrow_mut().set_lines(vec![]);
747 app.working_section.borrow_mut().set_lines(vec![]);
748 return;
749 }
750
751 let mut status_lines = Vec::new();
753 if let Some(ref status) = app.status_text {
754 let line = app.theme.fg_key(ThemeKey::Dim, &format!(" {}", status));
755 status_lines.push(crate::agent::ui::render_utils::pad_to_width(&line, width));
756 }
757
758 if app.is_streaming {
760 if let Some(ref msg) = app.pending_submit {
762 let preview = if msg.len() > 60 {
763 format!("{}…", &msg[..60])
764 } else {
765 msg.clone()
766 };
767 let line = app
768 .theme
769 .fg_key(ThemeKey::Dim, &format!(" 📝 queued: {}", preview));
770 status_lines.push(crate::agent::ui::render_utils::pad_to_width(&line, width));
771 }
772 }
773 app.status_section.borrow_mut().set_lines(status_lines);
774
775 let mut working_lines = Vec::new();
777 let wl = app.working.render(width);
778 working_lines.extend(wl);
779 app.working_section.borrow_mut().set_lines(working_lines);
780}
781
782fn user_agent_message(text: &str) -> yoagent::types::AgentMessage {
784 yoagent::types::AgentMessage::Llm(yoagent::types::Message::User {
785 content: vec![yoagent::types::Content::Text {
786 text: text.to_string(),
787 }],
788 timestamp: yoagent::types::now_ms(),
789 })
790}
791
792fn handle_input(app: &mut App, tui: &mut TUI, term: &mut ProcessTerminal, key: &KeyEvent) {
801 if app.session_picker.is_some() {
803 handle_session_picker_input(app, key);
804 return;
805 }
806
807 if tui.has_overlays() {
809 tui.pop_overlay();
810 return;
811 }
812
813 if tui.root.handle_input(key) {
818 return;
819 }
820
821 let action = app.editor.borrow_mut().handle_input(key);
824 match action {
825 InputAction::Handled => {}
826 InputAction::Escape => {
827 if app.is_streaming {
829 interrupt_streaming(app);
830 } else {
831 app.editor.borrow_mut().editor.set_text("");
832 }
833 }
834 InputAction::Clear => {
835 handle_clear(app);
836 }
837 InputAction::Exit => {
838 app.should_quit = true;
839 }
840 InputAction::ThinkingCycle => {
841 handle_thinking_cycle(app);
842 }
843 InputAction::ModelSelector => {
844 open_model_selector(app, tui);
845 }
846 InputAction::ModelCycleForward => {
847 handle_model_cycle(app, 1);
848 }
849 InputAction::ModelCycleBackward => {
850 handle_model_cycle(app, -1);
851 }
852 InputAction::ToggleThinking => {
853 app.hide_thinking = !app.hide_thinking;
854 {
856 let mut chat = app.chat_container.borrow_mut();
857 for child in chat.children_mut().iter_mut() {
858 child.set_hide_thinking(app.hide_thinking);
859 }
860 }
861 if let Some(weak) = app.streaming_component.as_ref().and_then(|w| w.upgrade()) {
863 weak.borrow_mut().set_hide_thinking(app.hide_thinking);
864 }
865 app.settings.set_hide_thinking(Some(app.hide_thinking));
867 if let Err(e) = app.settings.save() {
868 app.status_text = Some(format!("Failed to save thinking visibility: {}", e));
869 }
870 show_status(
871 app,
872 if app.hide_thinking {
873 "Thinking blocks: hidden".to_string()
874 } else {
875 "Thinking blocks: visible".to_string()
876 },
877 );
878 }
879 InputAction::ToolsExpand => {
880 handle_tools_expand(app);
881 }
882 InputAction::EditorExternal => {
883 handle_editor_external(app, tui, term);
884 }
885 InputAction::Help => {
886 show_help_overlay(app, tui);
887 }
888 InputAction::Submit(text) => {
889 submit_message(app, text);
890 }
891 InputAction::FollowUp(text) => {
892 handle_follow_up(app, text);
893 }
894 InputAction::Dequeue => {
895 if let Some(msg) = app.pending_submit.take() {
897 app.editor.borrow_mut().editor.set_text(&msg);
898 app.status_text = Some("Queued message restored to editor".into());
899 } else {
900 app.status_text = Some("No queued message".into());
901 }
902 }
903 InputAction::CompactToggle => {
904 handle_compact_toggle(app);
905 }
906 }
907}
908
909fn handle_clear(app: &mut App) {
915 let now = std::time::Instant::now();
916 let elapsed = now.duration_since(app.last_clear_time);
917 app.last_clear_time = now;
918
919 if app.is_streaming {
920 interrupt_streaming(app);
921 } else if elapsed.as_millis() < 500 {
922 app.should_quit = true;
924 } else {
925 app.editor.borrow_mut().editor.set_text("");
926 app.status_text = Some("Cleared".into());
927 }
928}
929
930fn handle_thinking_cycle(app: &mut App) {
932 if app.available_models.is_empty() && app.model.is_empty() {
933 app.status_text = Some("No model selected".into());
934 return;
935 }
936
937 let current = app.thinking_level.as_deref().unwrap_or("off");
938 let next = match THINKING_LEVELS.iter().position(|&l| l == current) {
939 Some(pos) => THINKING_LEVELS[(pos + 1) % THINKING_LEVELS.len()],
940 None => "off",
941 };
942
943 app.thinking_level = Some(next.to_string());
944 app.footer
945 .borrow_mut()
946 .set_thinking_level(Some(next.to_string()));
947 app.editor
948 .borrow_mut()
949 .update_border_color(Some(next), &app.theme as &dyn crate::tui::Theme);
950 app.settings
951 .set_default_thinking_level(Some(next.to_string()));
952 if let Err(e) = app.settings.save() {
953 app.status_text = Some(format!("Failed to save thinking level: {}", e));
954 }
955 if let Some(ref mut agent_session) = app.session {
957 agent_session.on_thinking_level_change(next);
958 }
959 app.status_text = Some(format!("Thinking level: {}", next));
960}
961
962fn handle_model_cycle(app: &mut App, dir: isize) {
964 let n = app.available_models.len();
965 if n == 0 {
966 app.status_text = Some("No models available".into());
967 return;
968 }
969
970 let current_idx = app.available_models.iter().position(|m| m == &app.model);
971
972 let next_idx = match current_idx {
973 Some(idx) => (idx as isize + dir).rem_euclid(n as isize) as usize,
974 None => 0,
975 };
976
977 app.model = app.available_models[next_idx].clone();
978 app.footer.borrow_mut().set_model(&app.model);
979 app.footer.borrow_mut().set_model_supports_reasoning(true);
981 if let Some(ref mut agent_session) = app.session {
983 agent_session.on_model_change("opencode-go", &app.model);
984 }
985 app.status_text = Some(format!("Model: {}", app.model));
986}
987
988fn handle_tools_expand(app: &mut App) {
992 app.tools_expanded = !app.tools_expanded;
993 app.collapse_tool_output = !app.tools_expanded;
994
995 app.header.borrow_mut().set_expanded(app.tools_expanded);
998
999 let mut chat = app.chat_container.borrow_mut();
1001 for child in chat.children_mut().iter_mut() {
1002 child.set_expanded(app.tools_expanded);
1003 }
1004 drop(chat);
1005
1006 app.settings
1007 .set_collapse_tool_output(Some(app.collapse_tool_output));
1008 if let Err(e) = app.settings.save() {
1009 app.status_text = Some(format!("Failed to save tool output setting: {}", e));
1010 }
1011 show_status(
1012 app,
1013 if app.tools_expanded {
1014 "Tool output: expanded".to_string()
1015 } else {
1016 "Tool output: collapsed".to_string()
1017 },
1018 );
1019}
1020
1021fn handle_editor_external(app: &mut App, tui: &mut TUI, term: &mut ProcessTerminal) {
1024 let editor_cmd = std::env::var("VISUAL")
1025 .or_else(|_| std::env::var("EDITOR"))
1026 .unwrap_or_default();
1027
1028 if editor_cmd.is_empty() {
1029 app.status_text = Some("No editor configured. Set $VISUAL or $EDITOR.".into());
1030 return;
1031 }
1032
1033 let tmp_dir = std::env::temp_dir();
1034 let tmp_file = tmp_dir.join(format!(
1035 "rab-editor-{}.md",
1036 std::time::SystemTime::now()
1037 .duration_since(std::time::UNIX_EPOCH)
1038 .map(|d| d.as_nanos())
1039 .unwrap_or(0)
1040 ));
1041
1042 let current_text = app.editor.borrow().editor.get_text();
1043 if let Err(e) = std::fs::write(&tmp_file, ¤t_text) {
1044 app.status_text = Some(format!("Failed to write temp file: {}", e));
1045 return;
1046 }
1047
1048 let parts: Vec<&str> = editor_cmd.split(' ').collect();
1049 let (editor, args) = parts.split_first().unwrap_or((&"", &[]));
1050
1051 app.status_text = Some(format!("Opening {} ...", editor_cmd));
1053 let mut suspend_buf = Vec::new();
1054 let _ = term.stop(&mut suspend_buf);
1055 let _ = term.show_cursor(&mut suspend_buf);
1056 if !suspend_buf.is_empty() {
1057 let stdout = std::io::stdout();
1058 let mut handle = stdout.lock();
1059 let _ = handle.write_all(&suspend_buf);
1060 let _ = handle.flush();
1061 }
1062
1063 crate::tui::terminal::stop_stdin_reader();
1065 crate::tui::terminal::join_stdin_reader();
1066
1067 let status = std::process::Command::new(editor)
1069 .args(args)
1070 .arg(&tmp_file)
1071 .status();
1072
1073 let mut resume_buf = Vec::new();
1075 let _ = term.start(&mut resume_buf);
1076 let _ = term.hide_cursor(&mut resume_buf);
1077 if !resume_buf.is_empty() {
1078 let stdout = std::io::stdout();
1079 let mut handle = stdout.lock();
1080 let _ = handle.write_all(&resume_buf);
1081 let _ = handle.flush();
1082 }
1083 crate::tui::terminal::start_stdin_reader();
1085 tui.request_render();
1087
1088 match status {
1089 Ok(status) if status.success() => {
1090 if let Ok(new_content) = std::fs::read_to_string(&tmp_file) {
1091 let trimmed = new_content.trim_end_matches('\n').to_string();
1092 app.editor.borrow_mut().editor.set_text(&trimmed);
1093 app.editor.borrow_mut().check_autocomplete();
1094 }
1095 let _ = std::fs::remove_file(&tmp_file);
1096 app.status_text = Some("Editor closed".into());
1097 }
1098 Ok(_) => {
1099 let _ = std::fs::remove_file(&tmp_file);
1100 app.status_text = Some("Editor exited with non-zero status".into());
1101 }
1102 Err(e) => {
1103 let _ = std::fs::remove_file(&tmp_file);
1104 app.status_text = Some(format!("Failed to launch editor: {}", e));
1105 }
1106 }
1107}
1108
1109fn handle_compact_toggle(app: &mut App) {
1112 app.auto_compact = !app.auto_compact;
1113 app.footer.borrow_mut().set_auto_compact(app.auto_compact);
1114
1115 if let Some(ref mut s) = app.session {
1117 s.set_auto_compact(app.auto_compact);
1118 }
1119
1120 app.settings.set_auto_compact(Some(app.auto_compact));
1122 if let Err(e) = app.settings.save() {
1123 eprintln!("Warning: failed to save auto_compact setting: {}", e);
1124 }
1125
1126 app.status_text = Some(if app.auto_compact {
1127 "Auto-compact: on".into()
1128 } else {
1129 "Auto-compact: off".into()
1130 });
1131}
1132
1133pub fn handle_follow_up(app: &mut App, text: String) {
1137 let trimmed = text.trim().to_string();
1138 if trimmed.is_empty() {
1139 return;
1140 }
1141
1142 if app.is_streaming && app.agent.as_ref().is_some_and(|a| a.is_streaming()) {
1143 let follow_msg = user_agent_message(&trimmed);
1144 if let Some(ref agent) = app.agent {
1145 agent.follow_up(follow_msg);
1146 app.status_text = Some("Follow-up queued — will send when agent finishes".into());
1147 }
1148 } else {
1149 if app.is_streaming {
1151 app.is_streaming = false;
1152 }
1153 submit_message(app, trimmed);
1154 }
1155}
1156
1157fn interrupt_streaming(app: &mut App) {
1159 if let Some(ref agent) = app.agent {
1161 agent.abort();
1162 }
1163 if let Some(handle) = app.forward_handle.take() {
1165 handle.abort();
1166 }
1167 if let Some(handle) = app.bash_abort_handle.take() {
1168 handle.abort();
1169 }
1170 app.agent = None;
1173 app.is_streaming = false;
1174 app.working.stop();
1175 app.footer.borrow_mut().set_streaming(false);
1176
1177 if let Some(ref s) = app.session {
1179 let ctx = s.session().build_session_context();
1180 let mut chat = app.chat_container.borrow_mut();
1181 rebuild_chat_from_messages(
1182 &mut chat,
1183 &ctx.messages,
1184 &app.cwd.to_string_lossy(),
1185 app.hide_thinking,
1186 app.collapse_tool_output,
1187 &app.extensions,
1188 );
1189 }
1190
1191 app.status_text = Some("Interrupted".into());
1192}
1193
1194fn open_model_selector(app: &mut App, tui: &mut TUI) {
1196 let models = app.available_models.clone();
1197 let current = app.model.clone();
1198 let selector = ModelSelector::new(models, ¤t, &app.theme);
1199 tui.show_overlay(Box::new(selector), Default::default());
1200}
1201
1202fn show_help_overlay(app: &mut App, tui: &mut TUI) {
1203 let mut overlay = crate::agent::ui::help::HelpOverlay::new(&app.theme);
1204 overlay.set_commands(app.commands.clone());
1205 tui.show_overlay(Box::new(overlay), Default::default());
1206}
1207
1208fn submit_message(app: &mut App, message: String) {
1213 app.scroll_offset = 0;
1214 let trimmed = message.trim().to_string();
1215
1216 if trimmed.is_empty() {
1218 return;
1219 }
1220
1221 if trimmed.starts_with("/skill:") {
1223 let expanded = expand_skill_command(&trimmed, &app.skills);
1224 if app.is_streaming && app.agent.as_ref().is_some_and(|a| a.is_streaming()) {
1225 let steer_msg = user_agent_message(&expanded);
1226 if let Some(ref agent) = app.agent {
1227 agent.steer(steer_msg);
1228 app.status_text = Some("Skill steering message sent".into());
1229 }
1230 return;
1231 }
1232 if app.is_streaming {
1233 app.is_streaming = false;
1235 app.working.stop();
1236 app.footer.borrow_mut().set_streaming(false);
1237 }
1238 app.pending_submit = Some(expanded);
1239 return;
1240 }
1241
1242 if trimmed.starts_with('/') {
1244 handle_slash_command(app, &trimmed);
1245 return;
1246 }
1247
1248 if let Some((cmd, _exclude)) = parse_bang_command(&trimmed) {
1250 handle_bang_command(app, cmd);
1251 return;
1252 }
1253
1254 if app.is_streaming {
1255 if app.agent.as_ref().is_some_and(|a| a.is_streaming()) {
1260 let steer_msg = user_agent_message(&trimmed);
1261 if let Some(ref agent) = app.agent {
1262 agent.steer(steer_msg);
1263 app.status_text = Some("Steering message sent — will be processed next".into());
1264 }
1265 if let Some(ref mut s) = app.session {
1267 s.reset_overflow_recovery();
1268 }
1269 return; } else {
1271 app.is_streaming = false;
1274 app.working.stop();
1275 app.footer.borrow_mut().set_streaming(false);
1276 }
1277 }
1278
1279 if let Some(ref mut s) = app.session {
1281 s.reset_overflow_recovery();
1282 }
1283
1284 app.pending_submit = Some(trimmed);
1286}
1287
1288fn build_fresh_agent(
1292 model: &str,
1293 api_key: &str,
1294 system_prompt: &str,
1295 thinking_level: yoagent::types::ThinkingLevel,
1296 messages: Vec<yoagent::types::AgentMessage>,
1297 extensions: &[Box<dyn Extension>],
1298) -> yoagent::agent::Agent {
1299 let mc = crate::agent::base_model_config(model);
1300
1301 let tools: Vec<Box<dyn yoagent::types::AgentTool>> = extensions
1302 .iter()
1303 .flat_map(|ext| ext.tools())
1304 .map(|twm| Box::new(twm) as Box<dyn yoagent::types::AgentTool>)
1305 .collect();
1306
1307 yoagent::agent::Agent::new(yoagent::provider::OpenAiCompatProvider)
1308 .with_model(model)
1309 .with_api_key(api_key)
1310 .with_model_config(mc)
1311 .with_system_prompt(system_prompt)
1312 .with_thinking(thinking_level)
1313 .with_messages(messages)
1314 .with_tools(tools)
1315 .without_context_management()
1316}
1317
1318fn map_thinking_level(level: Option<&str>) -> yoagent::types::ThinkingLevel {
1320 match level {
1321 Some("off") => yoagent::types::ThinkingLevel::Off,
1322 Some("low") => yoagent::types::ThinkingLevel::Low,
1323 Some("medium") => yoagent::types::ThinkingLevel::Medium,
1324 Some("high") | Some("xhigh") => yoagent::types::ThinkingLevel::High,
1325 _ => yoagent::types::ThinkingLevel::High,
1326 }
1327}
1328
1329async fn start_agent_loop(app: &mut App, message: String) {
1337 if app.session.is_none() {
1338 return;
1339 }
1340
1341 app.is_streaming = true;
1342 app.working.start();
1343 app.footer.borrow_mut().set_streaming(true);
1344
1345 let thinking = map_thinking_level(app.thinking_level.as_deref());
1346
1347 let msgs = app
1351 .session
1352 .as_ref()
1353 .map(|s| s.session().build_session_context().messages)
1354 .unwrap_or_default();
1355
1356 let agent: &mut yoagent::agent::Agent = match &mut app.agent {
1357 Some(existing) => {
1358 existing
1362 }
1363 None => {
1364 app.agent = Some(build_fresh_agent(
1365 &app.model,
1366 &app.api_key,
1367 &app.system_prompt,
1368 thinking,
1369 msgs,
1370 &app.extensions,
1371 ));
1372 app.agent.as_mut().unwrap()
1374 }
1375 };
1376
1377 if let Some(ref mut session) = app.session {
1379 session.on_model_change("opencode-go", &app.model);
1380 session.on_thinking_level_change(app.thinking_level.as_deref().unwrap_or("off"));
1381 }
1382
1383 let mut rx = agent.prompt(message).await;
1386
1387 let tx = app.event_tx.clone();
1390 let handle = tokio::spawn(async move {
1391 while let Some(event) = rx.recv().await {
1392 if tx.send(event).is_err() {
1393 break;
1394 }
1395 }
1396 });
1397 app.forward_handle = Some(handle);
1398}
1399
1400async fn handle_compact_command(app: &mut App, custom_instructions: Option<String>) {
1403 if app.session.is_none() {
1404 chat_add(
1405 app,
1406 std::boxed::Box::new(InfoMessageComponent::new(
1407 "No active session to compact".to_string(),
1408 )),
1409 );
1410 return;
1411 }
1412
1413 let agent_session = app.session.as_mut().unwrap();
1414
1415 app.working.start();
1416
1417 match agent_session
1418 .run_manual_compact(custom_instructions.as_deref())
1419 .await
1420 {
1421 Ok(_summary) => {
1422 app.working.stop();
1423 app.status_text = None;
1424 app.rebuild_from_session_context();
1425 show_status(app, "Compaction completed".to_string());
1426 }
1427 Err(e) => {
1428 app.working.stop();
1429 app.status_text = None;
1430 chat_add(
1431 app,
1432 std::boxed::Box::new(InfoMessageComponent::new(format!(
1433 "Compaction failed: {}",
1434 e
1435 ))),
1436 );
1437 }
1438 }
1439}
1440
1441async fn handle_auto_compact(app: &mut App) {
1445 if app.session.is_none() {
1446 return;
1447 }
1448
1449 let agent_session = app.session.as_mut().unwrap();
1450
1451 match agent_session.check_auto_compact().await {
1452 Ok(true) => {
1453 app.rebuild_from_session_context();
1454 if let Some(ref s) = app.session {
1456 app.footer.borrow_mut().refresh_from_session(s.session());
1457 }
1458 app.status_text = Some("Auto-compaction completed".to_string());
1459 }
1460 Ok(false) => {
1461 }
1463 Err(e) => {
1464 eprintln!("Warning: Auto-compaction failed: {}", e);
1465 app.status_text = Some(format!("Auto-compaction skipped: {}", e));
1466 }
1467 }
1468}
1469
1470fn handle_session_picker_input(app: &mut App, key: &crossterm::event::KeyEvent) {
1472 use crossterm::event::KeyCode;
1473
1474 let Some(ref mut picker) = app.session_picker else {
1475 return;
1476 };
1477
1478 match key.code {
1479 KeyCode::Esc => {
1480 app.session_picker = None;
1481 app.status_text = None;
1482 }
1483 KeyCode::Enter => {
1484 if let Some(path) = picker.selected_path() {
1485 let path = path.clone();
1486 app.session_picker = None;
1487 app.status_text = None;
1488 app.pending_command_result = Some(CommandResult::SessionSwitched { path });
1490 }
1491 }
1492 KeyCode::Up => {
1493 picker.select_prev();
1494 }
1495 KeyCode::Down => {
1496 picker.select_next();
1497 }
1498 KeyCode::Char('/') => {
1499 picker.set_filter("");
1500 }
1501 KeyCode::Char(c) => {
1502 let mut filter = picker.filter().to_string();
1503 filter.push(c);
1504 picker.set_filter(&filter);
1505 }
1506 KeyCode::Backspace => {
1507 let mut filter = picker.filter().to_string();
1508 filter.pop();
1509 picker.set_filter(&filter);
1510 }
1511 _ => {}
1512 }
1513}
1514
1515fn handle_slash_command(app: &mut App, input: &str) {
1520 let (cmd_name, args) = match input.split_once(' ') {
1521 Some((cmd, rest)) => (cmd.trim_start_matches('/'), rest),
1522 None => (input.trim_start_matches('/'), ""),
1523 };
1524
1525 for ext in app.extensions.iter() {
1527 for cmd in ext.commands() {
1528 if cmd.name == cmd_name {
1529 let result = cmd.handler.execute(args);
1532 match result {
1533 Ok(result) => {
1534 drop((ext, cmd));
1536 handle_command_result(app, result);
1537 return;
1538 }
1539 Err(e) => {
1540 drop((ext, cmd));
1541 chat_add(
1542 app,
1543 std::boxed::Box::new(InfoMessageComponent::new(format!(
1544 "Error executing /{}: {}",
1545 cmd_name, e
1546 ))),
1547 );
1548 return;
1549 }
1550 }
1551 }
1552 }
1553 }
1554
1555 let available: Vec<&str> = app.commands.iter().map(|(n, _)| n.as_str()).collect();
1557 app.status_text = Some(format!(
1558 "Unknown command: /{}. Available: {}",
1559 cmd_name,
1560 available.join(", ")
1561 ));
1562}
1563
1564fn handle_command_result(app: &mut App, result: CommandResult) {
1568 match result {
1569 CommandResult::Info(msg) => {
1570 chat_add(
1571 app,
1572 std::boxed::Box::new(InfoMessageComponent::new(msg.clone())),
1573 );
1574 }
1575 CommandResult::Quit => {
1576 app.should_quit = true;
1577 }
1578 CommandResult::ModelChanged(model) => {
1579 app.model = model.clone();
1580 app.footer.borrow_mut().set_model(&model);
1581 app.status_text = Some(format!("Model: {}", model));
1582 }
1583 CommandResult::ShowHelp => {
1584 app.pending_command_result = Some(result);
1586 }
1587 CommandResult::Reloaded => {
1588 if let Err(e) = app.settings.reload(&app.cwd) {
1590 app.status_text = Some(format!("Failed to reload settings: {}", e));
1591 } else {
1592 if let Some(level) = app.settings.default_thinking_level.clone() {
1594 app.thinking_level = Some(level.clone());
1595 app.footer
1596 .borrow_mut()
1597 .set_thinking_level(Some(level.clone()));
1598 }
1600 app.hide_thinking = app.settings.hide_thinking.unwrap_or(true);
1601 {
1603 let mut chat = app.chat_container.borrow_mut();
1604 for child in chat.children_mut().iter_mut() {
1605 child.set_hide_thinking(app.hide_thinking);
1606 }
1607 }
1608 if let Some(weak) = app.streaming_component.as_ref().and_then(|w| w.upgrade()) {
1610 weak.borrow_mut().set_hide_thinking(app.hide_thinking);
1611 }
1612 app.editor.borrow_mut().update_border_color(
1613 app.thinking_level.as_deref(),
1614 &app.theme as &dyn crate::tui::Theme,
1615 );
1616 chat_add(
1617 app,
1618 std::boxed::Box::new(InfoMessageComponent::new(
1619 "Settings, extensions, and keybindings reloaded.".to_string(),
1620 )),
1621 );
1622 }
1623 }
1624 CommandResult::NewSession => {
1625 app.working.stop();
1634
1635 app.status_text = None;
1637
1638 if let Some(ref mut agent_session) = app.session {
1640 agent_session.new_session();
1641 }
1642
1643 app.agent = None;
1645 app.clear_session_state();
1646
1647 if let Some(ref s) = app.session {
1649 app.footer.borrow_mut().refresh_from_session(s.session());
1650 }
1651
1652 let styled = app.theme.fg("accent", "✓ New session started");
1655 chat_add(app, std::boxed::Box::new(Text::new(styled, 1, 1, None)));
1656 }
1657 CommandResult::SessionSwitched { path } => {
1658 let new_session = crate::agent::AgentSession::open(&path, None, Some(&app.cwd));
1659 app.switch_to_session(new_session);
1660 app.status_text = Some(format!("Switched to session: {}", path.display()));
1661 }
1662 CommandResult::SessionInfo {
1663 session_id,
1664 file_path,
1665 name,
1666 message_count: _,
1667 user_messages: _,
1668 assistant_messages: _,
1669 tool_calls: _,
1670 tool_results: _,
1671 total_tokens: _,
1672 input_tokens: _,
1673 output_tokens: _,
1674 cache_read_tokens: _,
1675 cache_write_tokens: _,
1676 cost: _,
1677 } => {
1678 let msgs = app
1680 .session
1681 .as_ref()
1682 .map(|s| s.session().build_session_context().messages)
1683 .unwrap_or_default();
1684
1685 let name_display = name
1686 .or_else(|| {
1687 app.session
1688 .as_ref()
1689 .and_then(|s| s.session().session_name())
1690 })
1691 .unwrap_or_else(|| "unnamed".to_string());
1692 let file_display = file_path
1693 .as_ref()
1694 .map(|p| p.display().to_string())
1695 .unwrap_or_else(|| "in-memory".to_string());
1696 let sid = if session_id.is_empty() {
1697 app.session
1698 .as_ref()
1699 .map(|s| s.session().session_id())
1700 .unwrap_or_default()
1701 } else {
1702 session_id
1703 };
1704
1705 let user_messages = msgs
1706 .iter()
1707 .filter(|m| crate::agent::types::message_is_user(m))
1708 .count();
1709 let assistant_messages = msgs
1710 .iter()
1711 .filter(|m| crate::agent::types::message_is_assistant(m))
1712 .count();
1713 let tool_results = msgs
1714 .iter()
1715 .filter(|m| crate::agent::types::message_is_tool_result(m))
1716 .count();
1717 let tool_calls: usize = msgs
1718 .iter()
1719 .map(crate::agent::types::message_tool_call_count)
1720 .sum();
1721 let total_messages = user_messages + assistant_messages + tool_results;
1722
1723 let mut input_tokens: u64 = 0;
1724 let mut output_tokens: u64 = 0;
1725 let mut cache_read_tokens: u64 = 0;
1726 let cost: f64 = 0.0;
1727 for msg in &msgs {
1728 if let Some(usage) = crate::agent::types::message_usage(msg) {
1729 input_tokens += usage.input;
1730 output_tokens += usage.output;
1731 cache_read_tokens += usage.cache_read;
1732 }
1733 }
1734 let total_tokens = input_tokens + output_tokens + cache_read_tokens;
1735
1736 let mut info = format!(
1738 "Session Info\n\n\
1739 Name: {name_display}\n\
1740 File: {file_display}\n\
1741 ID: {sid}\n\
1742 \n\
1743 Messages\n\
1744 User: {user_messages}\n\
1745 Assistant: {assistant_messages}\n\
1746 Tool Calls: {tool_calls}\n\
1747 Tool Results: {tool_results}\n\
1748 Total: {total_messages}\n\
1749 \n\
1750 Tokens\n\
1751 Input: {}\n\
1752 Output: {}\n\
1753 Total: {}",
1754 format_number(input_tokens),
1755 format_number(output_tokens),
1756 format_number(total_tokens),
1757 );
1758 if cache_read_tokens > 0 {
1759 info += &format!("\nCache Read: {}", format_number(cache_read_tokens));
1760 }
1761 if cost > 0.0 {
1762 info += &format!("\n\nCost\nTotal: {:.4}", cost);
1763 }
1764
1765 if let Some(ref asession) = app.session
1767 && let Some(file_path) = asession.session().session_file().as_ref()
1768 && let Some(h) = crate::agent::session::read_session_header(file_path)
1769 && let Some(ref parent) = h.parent_session
1770 {
1771 info += &format!("\n\nParent: {}", parent);
1772 }
1773
1774 chat_add(
1775 app,
1776 std::boxed::Box::new(InfoMessageComponent::new(info.clone())),
1777 );
1778 }
1779 CommandResult::OpenSessionSelector => {
1780 use crate::agent::SessionRepo;
1782 let repo = crate::agent::DefaultSessionRepo::new();
1783 let sessions = repo.list_all(None);
1784
1785 if sessions.is_empty() {
1786 let msg = "No sessions found.".to_string();
1787 chat_add(
1788 app,
1789 std::boxed::Box::new(InfoMessageComponent::new(msg.clone())),
1790 );
1791 } else {
1792 let mut info = format!("Available Sessions ({} total)\n\n", sessions.len());
1793 for (i, s) in sessions.iter().take(20).enumerate() {
1794 let name = s.name.as_deref().unwrap_or("unnamed");
1795 let cwd_short = s.cwd.rsplit('/').next().unwrap_or(&s.cwd);
1796 info += &format!(
1797 "{}. {} [{}] {} msgs\n {}\n\n",
1798 i + 1,
1799 name,
1800 fmt_time_short(&s.created),
1801 s.message_count,
1802 cwd_short,
1803 );
1804 }
1805 if sessions.len() > 20 {
1806 info += &format!("... and {} more sessions\n", sessions.len() - 20);
1807 }
1808 info += "Use /resume to open the interactive picker";
1809
1810 chat_add(
1811 app,
1812 std::boxed::Box::new(InfoMessageComponent::new(info.clone())),
1813 );
1814 }
1815 }
1816 CommandResult::SessionNamed { name } => {
1817 app.status_text = Some(format!("Session name: {}", name));
1818
1819 if let Some(ref mut s) = app.session {
1821 s.session_mut().append_session_info(&name);
1822 }
1823
1824 app.update_session_info();
1826 if let Some(ref s) = app.session {
1827 app.footer.borrow_mut().refresh_from_session(s.session());
1828 }
1829 }
1830 CommandResult::OpenSettings => {
1831 app.pending_command_result = Some(result);
1833 }
1834 CommandResult::ScopedModels => {
1835 app.pending_command_result = Some(result);
1837 }
1838 CommandResult::ExportSession { path } => {
1839 let msg = if let Some(p) = path {
1840 format!("Export session to {} - not yet implemented.", p)
1841 } else {
1842 "Export session - not yet implemented (defaults to HTML).".to_string()
1843 };
1844 chat_add(
1845 app,
1846 std::boxed::Box::new(InfoMessageComponent::new(msg.clone())),
1847 );
1848 }
1849 CommandResult::ImportSession { path } => {
1850 let msg = format!("Import session from {} - not yet implemented.", path);
1851 chat_add(
1852 app,
1853 std::boxed::Box::new(InfoMessageComponent::new(msg.clone())),
1854 );
1855 }
1856 CommandResult::ShareSession => {
1857 let msg = "Share session - not yet implemented.".to_string();
1858 chat_add(
1859 app,
1860 std::boxed::Box::new(InfoMessageComponent::new(msg.clone())),
1861 );
1862 }
1863 CommandResult::CopyLastMessage => {
1864 let msg = "Copy last agent message to clipboard - not yet implemented.".to_string();
1865 chat_add(
1866 app,
1867 std::boxed::Box::new(InfoMessageComponent::new(msg.clone())),
1868 );
1869 }
1870 CommandResult::ShowChangelog => {
1871 let msg = "Changelog - not yet implemented.".to_string();
1872 chat_add(
1873 app,
1874 std::boxed::Box::new(InfoMessageComponent::new(msg.clone())),
1875 );
1876 }
1877 CommandResult::ForkSession { message_id } => {
1878 let source_path = app
1880 .session
1881 .as_ref()
1882 .and_then(|s| s.session().session_file());
1883 let session_dir = app.session.as_ref().map(|s| s.session_dir().to_path_buf());
1884 let cwd = app.cwd.clone();
1885
1886 match (source_path, session_dir) {
1887 (Some(ref source), Some(ref target_dir)) => {
1888 match crate::agent::session::fork_session(
1889 source,
1890 target_dir,
1891 message_id.as_deref(),
1892 None,
1893 ) {
1894 Ok(new_id) => {
1895 let dir_entries = std::fs::read_dir(target_dir).ok();
1897 let new_path = dir_entries.and_then(|entries| {
1898 entries
1899 .flatten()
1900 .find(|e| {
1901 let filename = e.file_name();
1902 filename.to_string_lossy().contains(&new_id)
1903 })
1904 .map(|e| e.path())
1905 });
1906
1907 match new_path {
1908 Some(ref path) => {
1909 let new_session =
1911 crate::agent::AgentSession::open(path, None, Some(&cwd));
1912 app.switch_to_session(new_session);
1913
1914 let styled = app.theme.fg(
1915 "accent",
1916 &format!("✓ Forked session: {}", path.display()),
1917 );
1918 chat_add(
1919 app,
1920 std::boxed::Box::new(Text::new(styled, 1, 1, None)),
1921 );
1922 }
1923 None => {
1924 let msg =
1925 format!("Fork created but new file not found: {}", new_id);
1926 chat_add(
1927 app,
1928 std::boxed::Box::new(InfoMessageComponent::new(msg)),
1929 );
1930 }
1931 }
1932 }
1933 Err(e) => {
1934 let msg = format!("Fork failed: {}", e);
1935 chat_add(
1936 app,
1937 std::boxed::Box::new(InfoMessageComponent::new(msg.clone())),
1938 );
1939 }
1940 }
1941 }
1942 _ => {
1943 let msg = "No active session to fork".to_string();
1944 chat_add(
1945 app,
1946 std::boxed::Box::new(InfoMessageComponent::new(msg.clone())),
1947 );
1948 }
1949 }
1950 }
1951 CommandResult::CloneSession => {
1952 let msg = "Clone session - not yet implemented.".to_string();
1953 chat_add(
1954 app,
1955 std::boxed::Box::new(InfoMessageComponent::new(msg.clone())),
1956 );
1957 }
1958 CommandResult::SessionTree => {
1959 let msg = "Session tree - not yet implemented.".to_string();
1960 chat_add(
1961 app,
1962 std::boxed::Box::new(InfoMessageComponent::new(msg.clone())),
1963 );
1964 }
1965 CommandResult::TrustDecision { decision } => {
1966 let msg = format!("Trust decision '{}' saved.", decision);
1967 chat_add(
1968 app,
1969 std::boxed::Box::new(InfoMessageComponent::new(msg.clone())),
1970 );
1971 }
1972 CommandResult::Login { provider: _ } => {
1973 app.pending_command_result = Some(result);
1975 }
1976 CommandResult::Logout { provider } => {
1977 let prov = provider.as_deref().unwrap_or("all providers");
1978 let msg = format!("Logged out from {} - not yet implemented.", prov);
1979 chat_add(
1980 app,
1981 std::boxed::Box::new(InfoMessageComponent::new(msg.clone())),
1982 );
1983 }
1984 CommandResult::CompactSession(custom_instructions) => {
1985 if app.is_streaming {
1987 interrupt_streaming(app);
1988 }
1989 app.pending_compact = Some(custom_instructions);
1990 }
1991 }
1992}
1993
1994fn find_tool_renderer(
1996 extensions: &[Box<dyn crate::agent::extension::Extension>],
1997 name: &str,
1998) -> Option<Arc<dyn ToolRenderer>> {
1999 for ext in extensions {
2000 for tool in ext.tools() {
2001 if tool.name() == name {
2002 return tool.renderer;
2003 }
2004 }
2005 }
2006 None
2007}
2008
2009fn handle_bang_command(app: &mut App, command: String) {
2013 let cwd = app.cwd.clone();
2014 let tx = app.event_tx.clone();
2015 use yoagent::types::{AgentEvent as YoEvent, Content as YoContent, ToolResult as YoResult};
2016
2017 let renderer = find_tool_renderer(&app.extensions, "bash");
2018 let mut tool = crate::agent::ui::components::ToolExecComponent::new(
2019 "bash",
2020 renderer,
2021 serde_json::json!({"command": command}),
2022 app.cwd.to_string_lossy().to_string(),
2023 "__bang__".to_string(),
2024 );
2025 tool.set_started_at(std::time::Instant::now());
2026 let (invalidate_tx, invalidate_rx) =
2027 crate::agent::ui::components::ToolExecComponent::make_invalidation_channel();
2028 app.invalidate_rxs.push(invalidate_rx);
2029 tool.set_invalidate_tx(invalidate_tx);
2030 tool.set_expanded(app.tools_expanded);
2031 let tool = Rc::new(RefCell::new(tool));
2032 app.pending_tools
2033 .insert("__bang__".to_string(), Rc::downgrade(&tool));
2034 chat_add(
2035 app,
2036 std::boxed::Box::new(crate::agent::ui::components::RcToolExec(tool)),
2037 );
2038 app.is_streaming = true;
2039 app.working.start();
2040 app.footer.borrow_mut().set_streaming(true);
2041 app.pending_tool_executions += 1;
2042
2043 let handle = tokio::spawn(async move {
2044 struct Guard<'a> {
2045 tx: &'a mpsc::UnboundedSender<yoagent::types::AgentEvent>,
2046 sent: bool,
2047 }
2048 impl Drop for Guard<'_> {
2049 fn drop(&mut self) {
2050 if !self.sent {
2051 let _ = self.tx.send(YoEvent::AgentEnd { messages: vec![] });
2052 }
2053 }
2054 }
2055 let mut guard = Guard {
2056 tx: &tx,
2057 sent: false,
2058 };
2059
2060 let mut child = match tokio::process::Command::new("sh")
2061 .arg("-c")
2062 .arg(&command)
2063 .current_dir(&cwd)
2064 .stdout(std::process::Stdio::piped())
2065 .stderr(std::process::Stdio::piped())
2066 .spawn()
2067 {
2068 Ok(c) => c,
2069 Err(e) => {
2070 let _ = tx.send(YoEvent::ToolExecutionEnd {
2071 tool_call_id: "__bang__".to_string(),
2072 tool_name: "bash".into(),
2073 result: YoResult {
2074 content: vec![YoContent::Text {
2075 text: format!("Failed to execute: {:#}", e),
2076 }],
2077 details: serde_json::Value::Null,
2078 },
2079 is_error: true,
2080 });
2081 guard.sent = true;
2082 let _ = tx.send(YoEvent::AgentEnd { messages: vec![] });
2083 return;
2084 }
2085 };
2086
2087 let mut all_output = String::new();
2088 use tokio::io::AsyncReadExt;
2090 let mut stdio = child.stdout.take().unwrap();
2091 let mut stderr = child.stderr.take().unwrap();
2092 let mut buf1 = [0u8; 4096];
2093 let mut buf2 = [0u8; 4096];
2094 let mut stdout_done = false;
2095 let mut stderr_done = false;
2096
2097 loop {
2098 tokio::select! {
2099 result = stdio.read(&mut buf1), if !stdout_done => {
2100 match result {
2101 Ok(0) => stdout_done = true,
2102 Ok(n) => {
2103 if let Ok(text) = std::str::from_utf8(&buf1[..n]) {
2104 all_output.push_str(text);
2105 let _ = tx.send(YoEvent::ProgressMessage {
2106 tool_call_id: "__bang__".to_string(),
2107 tool_name: "bash".into(),
2108 text: text.to_string(),
2109 });
2110 }
2111 }
2112 Err(_) => stdout_done = true,
2113 }
2114 }
2115 result = stderr.read(&mut buf2), if !stderr_done => {
2116 match result {
2117 Ok(0) => stderr_done = true,
2118 Ok(n) => {
2119 if let Ok(text) = std::str::from_utf8(&buf2[..n]) {
2120 all_output.push_str(text);
2121 let _ = tx.send(YoEvent::ProgressMessage {
2122 tool_call_id: "__bang__".to_string(),
2123 tool_name: "bash".into(),
2124 text: text.to_string(),
2125 });
2126 }
2127 }
2128 Err(_) => stderr_done = true,
2129 }
2130 }
2131 }
2132 if stdout_done && stderr_done {
2133 break;
2134 }
2135 }
2136
2137 let status = child.wait().await;
2139 let is_error = match &status {
2140 Ok(s) => !s.success(),
2141 Err(_) => true,
2142 };
2143 let result = if all_output.trim().is_empty() {
2144 "(no output)".to_string()
2145 } else {
2146 all_output.trim().to_string()
2147 };
2148
2149 let _ = tx.send(YoEvent::ToolExecutionEnd {
2150 tool_call_id: "__bang__".to_string(),
2151 tool_name: "bash".into(),
2152 result: YoResult {
2153 content: vec![YoContent::Text { text: result }],
2154 details: serde_json::Value::Null,
2155 },
2156 is_error,
2157 });
2158 guard.sent = true;
2159 let _ = tx.send(YoEvent::AgentEnd { messages: vec![] });
2160 });
2161 app.bash_abort_handle = Some(handle.abort_handle());
2162}
2163
2164pub fn rebuild_chat_from_messages(
2168 chat: &mut crate::tui::Container,
2169 messages: &[yoagent::types::AgentMessage],
2170 cwd: &str,
2171 hide_thinking: bool,
2172 _collapse_tool_output: bool,
2173 extensions: &[Box<dyn crate::agent::extension::Extension>],
2174) {
2175 chat.clear();
2176 use std::collections::HashMap;
2177 let mut pending_tool_components: HashMap<
2178 String,
2179 Rc<RefCell<crate::agent::ui::components::ToolExecComponent>>,
2180 > = HashMap::new();
2181
2182 for msg in messages {
2183 if crate::agent::types::message_is_user(msg) {
2184 let text = crate::agent::types::message_text(msg);
2185 if text.is_empty() {
2186 continue;
2187 }
2188 if !chat.children().is_empty() {
2189 chat.add_child(std::boxed::Box::new(Spacer::new(1)));
2190 }
2191 chat.add_child(std::boxed::Box::new(
2192 crate::agent::ui::components::UserMessageComponent::new(text),
2193 ));
2194 } else if crate::agent::types::message_is_assistant(msg) {
2195 let text = crate::agent::types::message_text(msg);
2196 if let yoagent::types::AgentMessage::Llm(yoagent::types::Message::Assistant {
2197 content,
2198 ..
2199 }) = msg
2200 {
2201 let tcs = crate::agent::types::content_tool_calls(content);
2202 if !tcs.is_empty() {
2203 if !text.trim().is_empty() {
2205 if !chat.children().is_empty() {
2206 chat.add_child(std::boxed::Box::new(Spacer::new(1)));
2207 }
2208 let mut asst =
2209 crate::agent::ui::components::AssistantMessageComponent::new(&text);
2210 if hide_thinking {
2211 asst.set_hide_thinking(true);
2212 }
2213 chat.add_child(std::boxed::Box::new(asst));
2214 }
2215 for (id, name, args) in &tcs {
2217 let renderer = find_tool_renderer(extensions, name);
2218 let tool = crate::agent::ui::components::ToolExecComponent::new(
2219 name,
2220 renderer,
2221 args.clone(),
2222 cwd.to_string(),
2223 id.clone(),
2224 );
2225 let tool = Rc::new(RefCell::new(tool));
2226 chat.add_child(std::boxed::Box::new(
2227 crate::agent::ui::components::RcToolExec(tool.clone()),
2228 ));
2229 pending_tool_components.insert(id.clone(), tool);
2230 }
2231 } else if !text.trim().is_empty() {
2232 if !chat.children().is_empty() {
2234 chat.add_child(std::boxed::Box::new(Spacer::new(1)));
2235 }
2236 let mut asst =
2237 crate::agent::ui::components::AssistantMessageComponent::new(&text);
2238 if hide_thinking {
2239 asst.set_hide_thinking(true);
2240 }
2241 chat.add_child(std::boxed::Box::new(asst));
2242 }
2243 }
2244 } else if crate::agent::types::message_is_tool_result(msg) {
2245 let is_error = crate::agent::types::message_is_error(msg);
2246 let text = crate::agent::types::message_text(msg);
2247 if let Some(tc_id) = crate::agent::types::message_tool_call_id(msg)
2248 && let Some(tool) = pending_tool_components.remove(tc_id)
2249 {
2250 let clean = text
2251 .strip_prefix("✓ ")
2252 .or_else(|| text.strip_prefix("✗ "))
2253 .unwrap_or(&text);
2254 let mut tool = tool.borrow_mut();
2255 tool.set_result_with_details(clean, is_error, None);
2256 }
2257 } else if crate::agent::types::message_is_extension(msg) {
2258 if let Some(text) = crate::agent::types::message_extension_text(msg) {
2260 if !chat.children().is_empty() {
2261 chat.add_child(std::boxed::Box::new(Spacer::new(1)));
2262 }
2263 chat.add_child(std::boxed::Box::new(InfoMessageComponent::new(text)));
2264 }
2265 }
2266 }
2267}
2268
2269pub fn chat_add(app: &mut App, component: std::boxed::Box<dyn Component>) {
2273 let mut chat = app.chat_container.borrow_mut();
2274 if !chat.children().is_empty() {
2275 chat.add_child(std::boxed::Box::new(Spacer::new(1)));
2276 }
2277 chat.add_child(component);
2278}
2279
2280fn show_status(app: &mut App, message: String) {
2287 let mut chat = app.chat_container.borrow_mut();
2288 if let Some(prev_len) = app.last_status_len
2290 && chat.len() == prev_len
2291 && prev_len >= 2
2292 {
2293 chat.pop_child(); chat.pop_child(); }
2296 app.last_status_len = None;
2297 drop(chat);
2298
2299 let mut chat = app.chat_container.borrow_mut();
2301 if !chat.children().is_empty() {
2302 chat.add_child(std::boxed::Box::new(Spacer::new(1)));
2303 }
2304 chat.add_child(std::boxed::Box::new(InfoMessageComponent::new(message)));
2305 app.last_status_len = Some(chat.len());
2306}
2307
2308fn handle_agent_event(app: &mut App, event: yoagent::types::AgentEvent) {
2315 match &event {
2318 E::MessageEnd { message } => {
2319 if crate::agent::types::message_is_user(message)
2322 && let Some(ref mut s) = app.session
2323 {
2324 s.reset_overflow_recovery();
2325 }
2326 if crate::agent::types::message_error(message).is_some()
2329 || crate::agent::types::message_is_system_stop(message)
2330 {
2331 } else if let Some(ref mut s) = app.session {
2333 s.on_agent_event(&event);
2334 }
2335 }
2336 E::ToolExecutionEnd { tool_call_id, .. } => {
2337 if tool_call_id != "__bang__"
2339 && let Some(ref mut s) = app.session
2340 {
2341 s.on_agent_event(&event);
2342 }
2343 }
2344 E::AgentEnd { .. } => {
2345 if let Some(ref mut s) = app.session {
2346 s.on_agent_event(&event);
2347 }
2348 }
2349 _ => {}
2350 }
2351
2352 use yoagent::types::AgentEvent as E;
2354 match event {
2355 E::AgentStart => {
2356 app.is_streaming = true;
2357 app.working.start();
2358 app.refresh_git_branch();
2359 }
2360 E::TurnStart => {}
2361 E::MessageStart { message } => {
2362 if crate::agent::types::message_is_user(&message) {
2366 let text = crate::agent::types::message_text(&message);
2367 if !text.is_empty() {
2368 chat_add(
2369 app,
2370 std::boxed::Box::new(
2371 crate::agent::ui::components::UserMessageComponent::new(&text),
2372 ),
2373 );
2374 }
2375 }
2376 }
2377 E::MessageUpdate { delta, .. } => {
2378 use yoagent::types::StreamDelta;
2379 match delta {
2380 StreamDelta::Text { delta } => {
2381 if let Some(weak) = app.streaming_component.as_ref().and_then(|w| w.upgrade()) {
2382 weak.borrow_mut().append_text(&delta);
2383 } else {
2384 use crate::tui::components::rc_ref_cell_component::RcRefCellComponent;
2385 let comp = Rc::new(RefCell::new(
2386 crate::agent::ui::components::AssistantMessageComponent::new(&delta),
2387 ));
2388 if app.hide_thinking {
2389 comp.borrow_mut().set_hide_thinking(true);
2390 }
2391 app.streaming_component = Some(Rc::downgrade(&comp));
2392 app.chat_container
2393 .borrow_mut()
2394 .add_child(std::boxed::Box::new(RcRefCellComponent(comp)));
2395 }
2396 }
2397 StreamDelta::Thinking { delta } => {
2398 if let Some(weak) = app.streaming_component.as_ref().and_then(|w| w.upgrade()) {
2399 weak.borrow_mut()
2400 .add_thinking(&delta, app.thinking_level.clone());
2401 } else {
2402 use crate::tui::components::rc_ref_cell_component::RcRefCellComponent;
2403 let mut comp =
2404 crate::agent::ui::components::AssistantMessageComponent::new("");
2405 comp.add_thinking(&delta, app.thinking_level.clone());
2406 if app.hide_thinking {
2407 comp.set_hide_thinking(true);
2408 }
2409 let comp = Rc::new(RefCell::new(comp));
2410 app.streaming_component = Some(Rc::downgrade(&comp));
2411 app.chat_container
2412 .borrow_mut()
2413 .add_child(std::boxed::Box::new(RcRefCellComponent(comp)));
2414 }
2415 }
2416 StreamDelta::ToolCallDelta { .. } => {}
2417 }
2418 }
2419 E::ToolExecutionStart {
2420 tool_call_id,
2421 tool_name,
2422 args,
2423 } => {
2424 app.pending_tool_executions += 1;
2425 app.streaming_component = None;
2426 let name = tool_name;
2427 let renderer = find_tool_renderer(&app.extensions, &name);
2428 let started_at = std::time::Instant::now();
2429 let (invalidate_tx, invalidate_rx) =
2430 crate::agent::ui::components::ToolExecComponent::make_invalidation_channel();
2431 app.invalidate_rxs.push(invalidate_rx);
2432 let comp: Rc<RefCell<_>> = {
2433 let mut tool = crate::agent::ui::components::ToolExecComponent::new(
2434 &name,
2435 renderer,
2436 args.clone(),
2437 app.cwd.to_string_lossy().to_string(),
2438 tool_call_id.clone(),
2439 );
2440 tool.set_started_at(std::time::Instant::now());
2441 tool.set_invalidate_tx(invalidate_tx);
2442 Rc::new(RefCell::new(tool))
2443 };
2444 comp.borrow_mut().set_expanded(app.tools_expanded);
2445 app.pending_tools
2446 .insert(tool_call_id.clone(), Rc::downgrade(&comp));
2447 app.tool_call_start_times
2448 .insert(tool_call_id.clone(), started_at);
2449 chat_add(
2450 app,
2451 std::boxed::Box::new(crate::agent::ui::components::RcToolExec(comp)),
2452 );
2453 }
2454 E::ToolExecutionUpdate {
2455 tool_call_id,
2456 partial_result,
2457 ..
2458 } => {
2459 let partial_text: String = partial_result
2461 .content
2462 .iter()
2463 .filter_map(|c| {
2464 if let yoagent::types::Content::Text { text } = c {
2465 Some(text.clone())
2466 } else {
2467 None
2468 }
2469 })
2470 .collect::<Vec<_>>()
2471 .join("");
2472 if !partial_text.is_empty()
2473 && let Some(weak) = app.pending_tools.get(&tool_call_id)
2474 && let Some(comp) = weak.upgrade()
2475 {
2476 comp.borrow_mut().append_output(&partial_text);
2477 }
2478 }
2479 E::ToolExecutionEnd {
2480 tool_call_id,
2481 tool_name: _,
2482 result,
2483 is_error,
2484 } => {
2485 app.pending_tool_executions = app.pending_tool_executions.saturating_sub(1);
2486 let content: String = result
2487 .content
2488 .iter()
2489 .filter_map(|c| {
2490 if let yoagent::types::Content::Text { text } = c {
2491 Some(text.clone())
2492 } else {
2493 None
2494 }
2495 })
2496 .collect::<Vec<_>>()
2497 .join("");
2498 if let Some(weak) = app.pending_tools.get(&tool_call_id)
2499 && let Some(comp) = weak.upgrade()
2500 {
2501 comp.borrow_mut()
2502 .set_result_with_details(&content, is_error, Some(result.details));
2503 app.tool_call_start_times.remove(&tool_call_id);
2504 }
2505 }
2506 E::ProgressMessage {
2507 text, tool_name, ..
2508 } => {
2509 if let Some(weak) = app.pending_tools.get("__bang__")
2511 && let Some(comp) = weak.upgrade()
2512 {
2513 comp.borrow_mut().append_output(&text);
2514 } else if tool_name.is_empty() {
2515 app.status_text = Some(text.trim().to_string());
2517 }
2518 }
2519 E::TurnEnd { message, .. } => {
2520 app.streaming_component = None;
2521 if let Some(err) = crate::agent::types::message_error(&message) {
2523 chat_add(
2524 app,
2525 std::boxed::Box::new(InfoMessageComponent::new(format!(
2526 "Provider error: {}",
2527 err
2528 ))),
2529 );
2530 }
2531 }
2532 E::AgentEnd { messages } => {
2533 app.streaming_component = None;
2534 app.is_streaming = false;
2535 app.working.stop();
2536 app.footer.borrow_mut().set_streaming(false);
2537 if let Some(ref s) = app.session {
2539 app.footer.borrow_mut().refresh_from_session(s.session());
2540 }
2541 app.pending_auto_compact = app.auto_compact;
2544 for msg in messages.iter().rev() {
2550 if let Some(yoagent::types::Message::Assistant {
2551 content,
2552 stop_reason,
2553 error_message,
2554 ..
2555 }) = msg.as_llm()
2556 && stop_reason != &yoagent::types::StopReason::ToolUse
2557 {
2558 if let Some(err) = error_message {
2559 chat_add(
2560 app,
2561 std::boxed::Box::new(InfoMessageComponent::new(format!(
2562 "Provider error: {}",
2563 err
2564 ))),
2565 );
2566 break;
2567 }
2568 let has_visible = content.iter().any(|c| match c {
2572 yoagent::types::Content::Text { text } => !text.trim().is_empty(),
2573 yoagent::types::Content::ToolCall { .. } => true,
2574 _ => false,
2575 });
2576 if !has_visible {
2577 chat_add(
2578 app,
2579 std::boxed::Box::new(InfoMessageComponent::new(
2580 "The agent returned an empty response. \
2581 This can happen when the provider's context \
2582 limit is exceeded or the model declined to \
2583 respond. Try sending a new message."
2584 .to_string(),
2585 )),
2586 );
2587 break;
2588 }
2589 }
2590 }
2591 }
2592 E::MessageEnd { message } => {
2593 if let Some(err) = crate::agent::types::message_error(&message) {
2596 chat_add(
2597 app,
2598 std::boxed::Box::new(InfoMessageComponent::new(err.to_string())),
2599 );
2600 let ext = crate::agent::types::extension_message("error", err, true);
2601 if let Some(ref mut s) = app.session {
2602 s.persist_extension_message(&ext);
2603 }
2604 } else if crate::agent::types::message_is_system_stop(&message) {
2605 let text = crate::agent::types::message_text(&message);
2606 chat_add(
2607 app,
2608 std::boxed::Box::new(InfoMessageComponent::new(text.clone())),
2609 );
2610 if let Some(ref mut s) = app.session {
2611 let ext = crate::agent::types::extension_message("system_stop", text, true);
2612 s.persist_extension_message(&ext);
2613 }
2614 } else if crate::agent::types::message_is_extension(&message) {
2615 if let Some(text) = crate::agent::types::message_extension_text(&message) {
2617 chat_add(app, std::boxed::Box::new(InfoMessageComponent::new(text)));
2618 }
2619 }
2620 }
2621 E::InputRejected { reason } => {
2622 let msg = format!("Input rejected: {}", reason);
2623 chat_add(
2624 app,
2625 std::boxed::Box::new(InfoMessageComponent::new(msg.clone())),
2626 );
2627 }
2628 }
2629}
2630
2631fn parse_bang_command(input: &str) -> Option<(String, bool)> {
2633 if let Some(rest) = input.strip_prefix("!!") {
2634 let cmd = rest.trim();
2635 if cmd.is_empty() {
2636 None
2637 } else {
2638 Some((cmd.to_string(), true))
2639 }
2640 } else if let Some(rest) = input.strip_prefix('!') {
2641 let cmd = rest.trim();
2642 if cmd.is_empty() {
2643 None
2644 } else {
2645 Some((cmd.to_string(), false))
2646 }
2647 } else {
2648 None
2649 }
2650}
2651
2652fn format_number(n: u64) -> String {
2654 let s = n.to_string();
2655 let mut result = String::new();
2656 for (i, c) in s.chars().rev().enumerate() {
2657 if i > 0 && i % 3 == 0 {
2658 result.push(',');
2659 }
2660 result.push(c);
2661 }
2662 result.chars().rev().collect()
2663}
2664
2665fn fmt_time_short(dt: &chrono::DateTime<chrono::Utc>) -> String {
2667 dt.format("%Y-%m-%d %H:%M").to_string()
2668}
2669
2670fn xml_escape(s: &str) -> String {
2673 s.replace('&', "&")
2674 .replace('<', "<")
2675 .replace('>', ">")
2676 .replace('"', """)
2677 .replace('\'', "'")
2678}
2679
2680fn strip_frontmatter(content: &str) -> String {
2681 let content = content.trim_start();
2682 if !content.starts_with("---") {
2683 return content.to_string();
2684 }
2685 let remaining = &content[3..];
2686 let end = match remaining.find("---") {
2687 Some(pos) => pos,
2688 None => return content.to_string(),
2689 };
2690 let body_start = 3 + end + 3;
2691 content[body_start..].trim().to_string()
2692}
2693
2694fn read_skill_body(file_path: &std::path::Path) -> Option<String> {
2695 let content = std::fs::read_to_string(file_path).ok()?;
2696 Some(strip_frontmatter(&content))
2697}
2698
2699fn format_skill_invocation(skill: &yoagent::skills::Skill, extra: Option<&str>) -> String {
2700 let body = read_skill_body(&skill.file_path).unwrap_or_default();
2701 let base = skill.base_dir.to_string_lossy();
2702 let block = format!(
2703 r#"<skill name="{}" location="{}">
2704References are relative to {}.
2705
2706{}
2707</skill>"#,
2708 xml_escape(&skill.name),
2709 xml_escape(&skill.file_path.to_string_lossy()),
2710 base,
2711 body
2712 );
2713 match extra {
2714 Some(instr) if !instr.is_empty() => format!("{}\n\n{}", block, instr),
2715 _ => block,
2716 }
2717}
2718
2719fn expand_skill_command(text: &str, skills: &[yoagent::skills::Skill]) -> String {
2720 if !text.starts_with("/skill:") {
2721 return text.to_string();
2722 }
2723 let rest = &text[7..];
2724 let (skill_name, args) = match rest.find(' ') {
2725 Some(pos) => (&rest[..pos], rest[pos + 1..].trim()),
2726 None => (rest, ""),
2727 };
2728 match skills.iter().find(|s| s.name == skill_name) {
2729 Some(s) => format_skill_invocation(s, if args.is_empty() { None } else { Some(args) }),
2730 None => text.to_string(),
2731 }
2732}