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