1use std::cell::RefCell;
2use std::collections::HashMap;
3use std::path::PathBuf;
4use std::rc::{Rc, Weak};
5use std::sync::Arc;
6use std::time::Duration;
7
8use crate::agent::extension::{AgentTool, Extension};
9use crate::agent::provider::{Provider, ToolDef};
10use crate::agent::session::SessionManager;
11use crate::agent::types::{AgentMessage, PendingMessageQueue, QueueMode, ToolExecutionMode, Usage};
12use crate::agent::ui::chat_editor::{ChatEditor, InputAction};
13use crate::agent::ui::components::EditorComponent;
14use crate::agent::ui::components::FooterComponent;
15use crate::agent::ui::components::InfoMessageComponent;
16use crate::agent::ui::footer::Footer;
17use crate::agent::ui::messages::{DisplayMsg, session_messages_to_display};
18use crate::agent::ui::model_selector::ModelSelector;
19use crate::agent::ui::theme::RabTheme;
20use crate::agent::ui::working::WorkingIndicator;
21use crate::agent::{AgentEvent, LoopConfig, run_agent_loop};
22use crate::tui::Component;
23use crate::tui::TUI;
24use crate::tui::components::RefContainer;
25use crate::tui::components::Spacer;
26use crate::tui::terminal::{self, ProcessTerminal, TerminalTrait};
27use crossterm::event::KeyEvent;
28use tokio::sync::mpsc;
29
30const THINKING_LEVELS: &[&str] = &["xhigh", "high", "medium", "low", "off"];
34
35pub struct AppConfig {
37 pub model: String,
38 pub system_prompt: String,
39 pub tools: Vec<ToolDef>,
40 pub agent_tools: Vec<Box<dyn AgentTool>>,
41 pub extensions: Vec<Box<dyn Extension>>,
42 pub provider: Box<dyn Provider>,
43 pub cwd: PathBuf,
44 pub thinking_level: Option<String>,
45 pub git_branch: Option<String>,
46 pub available_models: Vec<String>,
47 pub hide_thinking: bool,
48 pub collapse_tool_output: bool,
49 pub interactive: bool,
50 pub settings: crate::agent::settings::Settings,
51 pub context_files: Vec<String>,
53 pub tool_execution: ToolExecutionMode,
55 pub skills: Vec<crate::agent::Skill>,
57 pub model_supports_reasoning: bool,
59}
60
61pub struct App {
63 cwd: PathBuf,
64 model: String,
65 #[allow(dead_code)]
66 thinking_level: Option<String>,
67 system_prompt: String,
68 provider: Arc<dyn Provider>,
69 theme: RabTheme,
70
71 #[allow(dead_code)]
73 commands: Vec<(String, String)>,
74
75 available_models: Vec<String>,
77
78 conversation: Vec<AgentMessage>,
80
81 messages: Vec<DisplayMsg>,
83
84 pub chat_container: RefContainer,
87
88 pub pending_section: std::rc::Rc<crate::tui::components::DynamicLines>,
91 pub status_section: std::rc::Rc<crate::tui::components::DynamicLines>,
93 pub queued_section: std::rc::Rc<crate::tui::components::DynamicLines>,
95 pub working_section: std::rc::Rc<crate::tui::components::DynamicLines>,
97
98 editor: Rc<RefCell<ChatEditor>>,
100
101 event_tx: mpsc::UnboundedSender<AgentEvent>,
103 event_rx: mpsc::UnboundedReceiver<AgentEvent>,
104
105 is_streaming: bool,
107 pending_text: Option<String>,
108 pending_thinking: Option<String>,
109
110 hide_thinking: bool,
112 collapse_tool_output: bool,
113 tools_expanded: bool,
115
116 scroll_offset: usize,
118
119 last_clear_time: std::time::Instant,
121
122 should_quit: bool,
124
125 last_usage: Option<Usage>,
127
128 agent_abort: Option<tokio::task::AbortHandle>,
130
131 session: Option<SessionManager>,
133
134 footer: Rc<RefCell<Footer>>,
136
137 pending_tools: HashMap<String, Weak<RefCell<crate::agent::ui::components::ToolExecComponent>>>,
140
141 tool_call_start_times: HashMap<String, std::time::Instant>,
144
145 streaming_component:
148 Option<Weak<RefCell<crate::agent::ui::components::AssistantMessageComponent>>>,
149
150 bash_component: Option<Weak<RefCell<crate::agent::ui::components::BashExecution>>>,
152
153 working: WorkingIndicator,
155
156 status_text: Option<String>,
158
159 agent_tools: Arc<Vec<Box<dyn AgentTool>>>,
161 extensions: Arc<Vec<Box<dyn Extension>>>,
163
164 steering_queue: Arc<std::sync::Mutex<PendingMessageQueue>>,
167 follow_up_queue: Arc<std::sync::Mutex<PendingMessageQueue>>,
170 tool_execution: ToolExecutionMode,
172
173 skills: Vec<crate::agent::Skill>,
175
176 auto_compact: bool,
178
179 settings: crate::agent::settings::Settings,
181
182 header: Rc<RefCell<crate::agent::ui::components::HeaderComponent>>,
187 }
190
191impl App {
192 fn new(config: AppConfig, session: SessionManager) -> Self {
193 let (tx, rx) = mpsc::unbounded_channel();
194 use crate::agent::ui::theme::current_theme;
195 let theme = current_theme().clone();
196
197 let mut editor = ChatEditor::new(&theme, config.cwd.clone());
198
199 let commands: Vec<(String, String)> = config
201 .extensions
202 .iter()
203 .flat_map(|e| e.commands())
204 .map(|c| (c.name, c.description))
205 .collect();
206 editor.set_slash_commands(commands.iter().map(|(n, _)| n.clone()).collect());
207
208 let editor = Rc::new(RefCell::new(editor));
209
210 let mut footer = Footer::new(config.cwd.to_string_lossy().to_string());
211 footer.set_git_branch(config.git_branch.clone());
212 footer.set_model(&config.model);
213 footer.set_model_supports_reasoning(config.model_supports_reasoning);
214 footer.set_thinking_level(config.thinking_level.clone());
215
216 let footer = Rc::new(RefCell::new(footer));
217
218 let context = session.build_session_context();
220 let history_messages = context.messages.clone();
221 let history_display = session_messages_to_display(&history_messages);
222
223 let mut startup_info: Vec<DisplayMsg> = Vec::new();
225
226 let mut resource_parts: Vec<String> = Vec::new();
227
228 if !config.context_files.is_empty() {
229 let ctx = config.context_files.join(", ");
230 resource_parts.push(format!("Context: {}", ctx));
231 }
232
233 if !config.skills.is_empty() {
234 let skill_names: Vec<&str> = config.skills.iter().map(|s| s.name.as_str()).collect();
235 resource_parts.push(format!("Skills: {}", skill_names.join(", ")));
236 }
237
238 if !resource_parts.is_empty() {
239 startup_info.push(DisplayMsg::Info(resource_parts.join(" · ")));
240 }
241
242 let messages = if startup_info.is_empty() {
244 history_display
245 } else {
246 let mut combined = startup_info;
247 combined.push(DisplayMsg::Separator);
248 combined.extend(history_display);
249 combined
250 };
251
252 let chat_container = RefContainer::new();
255 {
256 let mut chat = chat_container.inner.borrow_mut();
257 for display_msg in &messages {
258 if let Some(component) =
259 crate::agent::ui::components::display_msg_to_component(display_msg)
260 {
261 if !chat.children().is_empty() {
262 chat.add_child(std::boxed::Box::new(crate::tui::components::Spacer::new(
263 1,
264 )));
265 }
266 chat.add_child(component);
267 }
268 }
269 }
270
271 Self {
272 cwd: config.cwd,
273 model: config.model,
274 thinking_level: config.thinking_level,
275 system_prompt: config.system_prompt,
276 provider: Arc::from(config.provider),
277 theme,
278 commands,
279 available_models: config.available_models,
280 conversation: history_messages,
281 messages,
282 chat_container,
283 pending_tools: HashMap::new(),
284 tool_call_start_times: HashMap::new(),
285 streaming_component: None,
286 bash_component: None,
287 pending_section: std::rc::Rc::new(crate::tui::components::DynamicLines::new()),
288 status_section: std::rc::Rc::new(crate::tui::components::DynamicLines::new()),
289 queued_section: std::rc::Rc::new(crate::tui::components::DynamicLines::new()),
290 working_section: std::rc::Rc::new(crate::tui::components::DynamicLines::new()),
291 editor,
292 event_tx: tx,
293 event_rx: rx,
294 is_streaming: false,
295 pending_text: None,
296 pending_thinking: None,
297 hide_thinking: config.hide_thinking,
298 collapse_tool_output: config.collapse_tool_output,
299 tools_expanded: !config.collapse_tool_output,
300 scroll_offset: 0,
301 last_clear_time: std::time::Instant::now(),
302
303 should_quit: false,
304 last_usage: None,
305 agent_abort: None,
306 session: Some(session),
307 footer,
308 working: WorkingIndicator::new(),
309 agent_tools: Arc::new(config.agent_tools),
310 extensions: Arc::new(config.extensions),
311 steering_queue: Arc::new(std::sync::Mutex::new(PendingMessageQueue::new(
312 QueueMode::OneAtATime,
313 ))),
314 follow_up_queue: Arc::new(std::sync::Mutex::new(PendingMessageQueue::new(
315 QueueMode::OneAtATime,
316 ))),
317 tool_execution: config.tool_execution,
318 skills: config.skills,
319 settings: config.settings,
320 auto_compact: true,
321 status_text: None,
322 header: Rc::new(RefCell::new(
323 crate::agent::ui::components::HeaderComponent::new(),
324 )),
325 }
326 }
327
328 fn refresh_git_branch(&self) {
331 if let Ok(output) = std::process::Command::new("git")
332 .args([
333 "-C",
334 &self.cwd.to_string_lossy(),
335 "rev-parse",
336 "--abbrev-ref",
337 "HEAD",
338 ])
339 .output()
340 && output.status.success()
341 {
342 let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
343 if !branch.is_empty() {
344 self.footer.borrow_mut().set_git_branch(Some(branch));
345 }
346 }
347 }
348}
349
350pub async fn run(config: AppConfig, session: SessionManager) -> anyhow::Result<()> {
352 crate::agent::ui::theme::init_theme(Some("dark"), false);
354
355 let mut term = ProcessTerminal::new();
356 let mut stdout = std::io::stdout();
357
358 term.start(&mut stdout)?;
362 term.hide_cursor(&mut stdout)?;
363 term.set_color_scheme_notifications(&mut stdout, true)?;
364
365 let mut tui = TUI::new();
366 tui.set_clear_on_shrink(false);
369 let mut app = App::new(config, session);
370
371 tui.root.add_child(std::boxed::Box::new(
374 crate::tui::components::RcRefCellComponent(
375 app.header.clone() as Rc<RefCell<dyn Component>>,
376 ),
377 ));
378 tui.root
379 .add_child(std::boxed::Box::new(app.chat_container.clone()));
380 tui.root.add_child(std::boxed::Box::new(
381 crate::tui::components::RcDynamicLines(app.pending_section.clone()),
382 ));
383 tui.root.add_child(std::boxed::Box::new(
384 crate::tui::components::RcDynamicLines(app.status_section.clone()),
385 ));
386 tui.root.add_child(std::boxed::Box::new(
387 crate::tui::components::RcDynamicLines(app.queued_section.clone()),
388 ));
389 tui.root.add_child(std::boxed::Box::new(
390 crate::tui::components::RcDynamicLines(app.working_section.clone()),
391 ));
392 tui.root
393 .add_child(std::boxed::Box::new(EditorComponent(app.editor.clone())));
394 tui.root
395 .add_child(std::boxed::Box::new(FooterComponent(app.footer.clone())));
396
397 app.editor.borrow_mut().update_border_color(
399 app.thinking_level.as_deref(),
400 &app.theme as &dyn crate::tui::Theme,
401 );
402
403 let mut cols: u16 = 80;
406 let mut rows: u16 = 24;
407 let mut dirty = true; loop {
410 let timeout = if dirty || app.is_streaming || app.working.active {
414 Duration::from_millis(16)
415 } else {
416 Duration::from_millis(50)
417 };
418
419 if let Some(evt) = terminal::poll_terminal_event(Some(timeout))? {
420 match evt {
421 terminal::TerminalEvent::Key(key) => {
422 if !tui.route_input(&key) {
424 handle_input(&mut app, &mut tui, &key);
425 }
426 }
427 terminal::TerminalEvent::Paste(content) => {
428 if !tui.route_paste(&content) {
431 app.editor.borrow_mut().editor.handle_paste(&content);
432 }
433 }
434 terminal::TerminalEvent::Resize(w, h) => {
435 app.editor.borrow_mut().editor.set_terminal_rows(h as usize);
437 tui.set_dimensions(w as usize, h as usize);
438 }
439 }
440 dirty = true;
441 }
442
443 while let Ok(event) = app.event_rx.try_recv() {
445 handle_agent_event(&mut app, event);
446 dirty = true;
447 }
448
449 if dirty && let Ok((w, h)) = term.size() {
452 app.editor.borrow_mut().editor.set_terminal_rows(h as usize);
453 cols = w;
454 rows = h;
455 }
456
457 if app.working.tick() {
459 dirty = true;
460 }
461
462 let mut tools_to_remove: Vec<String> = Vec::new();
464 for (id, weak) in app.pending_tools.iter() {
465 if let Some(comp) = weak.upgrade() {
466 if comp.borrow_mut().tick_timer() {
467 dirty = true;
468 }
469 } else {
470 tools_to_remove.push(id.clone());
471 }
472 }
473 for id in tools_to_remove {
474 app.pending_tools.remove(&id);
475 }
476
477 if dirty {
479 compose_ui(&mut app, cols as usize);
481 tui.set_dimensions(cols as usize, rows as usize);
482 tui.render(cols as usize, rows as usize, &mut stdout)?;
483 dirty = false;
484 }
485
486 app.status_text = None;
488
489 if app.should_quit {
490 break;
491 }
492 }
493
494 tui.finalize(&mut stdout)?;
497 term.set_color_scheme_notifications(&mut stdout, false)?;
498 term.show_cursor(&mut stdout)?;
499 term.stop(&mut stdout)?;
500
501 Ok(())
502}
503
504fn compose_ui(app: &mut App, width: usize) {
510 let mut pending_lines = Vec::new();
512 if let Some(ref text) = app.pending_text
513 && !text.is_empty()
514 {
515 let inner = width.saturating_sub(2);
516 for line in text.lines() {
517 if line.is_empty() {
518 pending_lines.push(String::new());
519 } else {
520 let wrapped = crate::tui::util::wrap_text_with_ansi(line, inner);
521 for w in wrapped {
522 let line = format!(" {}", w);
523 pending_lines.push(crate::agent::ui::messages::pad_to_width(&line, width));
524 }
525 }
526 }
527 }
528 if let Some(ref text) = app.pending_thinking
529 && !text.is_empty()
530 {
531 if app.hide_thinking {
532 let content = format!(
533 " {} ",
534 app.theme
535 .italic(&app.theme.fg("thinking_text", "Thinking..."))
536 );
537 let padded = crate::agent::ui::messages::pad_to_width(&content, width);
538 pending_lines.push(app.theme.bg("thinking_bg", &padded));
539 } else {
540 let level_color = app
541 .thinking_level
542 .as_deref()
543 .and_then(crate::agent::ui::messages::thinking_level_color)
544 .unwrap_or("thinking_text");
545 for line in text.lines() {
546 let content = format!(" {}", app.theme.italic(&app.theme.fg(level_color, line)));
547 let padded = crate::agent::ui::messages::pad_to_width(&content, width);
548 pending_lines.push(app.theme.bg("thinking_bg", &padded));
549 }
550 }
551 }
552 app.pending_section.set_lines(pending_lines);
553
554 let mut status_lines = Vec::new();
556 if let Some(ref status) = app.status_text {
557 let line = app.theme.fg("dim", &format!(" {}", status));
558 status_lines.push(crate::agent::ui::messages::pad_to_width(&line, width));
559 }
560 app.status_section.set_lines(status_lines);
561
562 let mut queued_lines = Vec::new();
565 let steer_count = {
566 let q = app.steering_queue.lock().unwrap();
567 q.len()
568 };
569 let follow_count = {
570 let q = app.follow_up_queue.lock().unwrap();
571 q.len()
572 };
573 if steer_count > 0 {
574 let line = app.theme.fg(
575 "dim",
576 &format!(
577 " ◷ {} steer message{} pending",
578 steer_count,
579 if steer_count == 1 { "" } else { "s" }
580 ),
581 );
582 queued_lines.push(crate::agent::ui::messages::pad_to_width(&line, width));
583 }
584 if follow_count > 0 {
585 let line = app.theme.fg(
586 "dim",
587 &format!(
588 " ◷ {} follow-up message{} pending",
589 follow_count,
590 if follow_count == 1 { "" } else { "s" }
591 ),
592 );
593 queued_lines.push(crate::agent::ui::messages::pad_to_width(&line, width));
594 }
595 if steer_count > 0 || follow_count > 0 {
596 let hint = app
597 .theme
598 .fg("dim", " ↳ Esc to abort, Alt+↑ to restore follow-ups");
599 queued_lines.push(crate::agent::ui::messages::pad_to_width(&hint, width));
600 }
601 app.queued_section.set_lines(queued_lines);
602
603 let mut working_lines = Vec::new();
605 let wl = app.working.render(width);
606 working_lines.extend(wl);
607 app.working_section.set_lines(working_lines);
608}
609
610fn handle_input(app: &mut App, tui: &mut TUI, key: &KeyEvent) {
619 if tui.has_overlays() {
621 tui.pop_overlay();
622 return;
623 }
624
625 if tui.root.handle_input(key) {
630 return;
631 }
632
633 let action = app.editor.borrow_mut().handle_input(key);
636 match action {
637 InputAction::Handled => {}
638 InputAction::Escape => {
639 if app.is_streaming {
641 interrupt_streaming(app);
642 } else {
643 app.editor.borrow_mut().editor.set_text("");
644 }
645 }
646 InputAction::Clear => {
647 handle_clear(app);
648 }
649 InputAction::Exit => {
650 app.should_quit = true;
651 }
652 InputAction::ThinkingCycle => {
653 handle_thinking_cycle(app);
654 }
655 InputAction::ModelSelector => {
656 open_model_selector(app, tui);
657 }
658 InputAction::ModelCycleForward => {
659 handle_model_cycle(app, 1);
660 }
661 InputAction::ModelCycleBackward => {
662 handle_model_cycle(app, -1);
663 }
664 InputAction::ToggleThinking => {
665 app.hide_thinking = !app.hide_thinking;
666 {
668 let mut chat = app.chat_container.inner.borrow_mut();
669 for child in chat.children_mut().iter_mut() {
670 child.set_hide_thinking(app.hide_thinking);
671 }
672 }
673 if let Some(weak) = app.streaming_component.as_ref().and_then(|w| w.upgrade()) {
675 weak.borrow_mut().set_hide_thinking(app.hide_thinking);
676 }
677 let _ = crate::agent::settings::save_field("hideThinkingBlock", app.hide_thinking);
679 app.settings.hide_thinking = Some(app.hide_thinking);
680 chat_add(
681 app,
682 Box::new(InfoMessageComponent::new(if app.hide_thinking {
683 "Thinking blocks: hidden".to_string()
684 } else {
685 "Thinking blocks: visible".to_string()
686 })),
687 );
688 }
689 InputAction::ToolsExpand => {
690 handle_tools_expand(app);
691 }
692 InputAction::EditorExternal => {
693 handle_editor_external(app);
694 }
695 InputAction::Help => {
696 show_help_overlay(app, tui);
697 }
698 InputAction::Submit(text) => {
699 submit_message(app, text);
700 }
701 InputAction::FollowUp(text) => {
702 handle_follow_up(app, text);
703 }
704 InputAction::Dequeue => {
705 handle_dequeue(app);
706 }
707 InputAction::CompactToggle => {
708 handle_compact_toggle(app);
709 }
710 }
711}
712
713fn handle_clear(app: &mut App) {
719 let now = std::time::Instant::now();
720 let elapsed = now.duration_since(app.last_clear_time);
721 app.last_clear_time = now;
722
723 if app.is_streaming {
724 interrupt_streaming(app);
725 } else if elapsed.as_millis() < 500 {
726 app.should_quit = true;
728 } else {
729 app.editor.borrow_mut().editor.set_text("");
730 app.status_text = Some("Cleared".into());
731 }
732}
733
734fn handle_thinking_cycle(app: &mut App) {
736 if app.available_models.is_empty() && app.model.is_empty() {
737 app.status_text = Some("No model selected".into());
738 return;
739 }
740
741 let current = app.thinking_level.as_deref().unwrap_or("off");
742 let next = match THINKING_LEVELS.iter().position(|&l| l == current) {
743 Some(pos) => THINKING_LEVELS[(pos + 1) % THINKING_LEVELS.len()],
744 None => "off",
745 };
746
747 app.thinking_level = Some(next.to_string());
748 app.footer
749 .borrow_mut()
750 .set_thinking_level(Some(next.to_string()));
751 app.editor
752 .borrow_mut()
753 .update_border_color(Some(next), &app.theme as &dyn crate::tui::Theme);
754 app.settings.default_thinking_level = Some(next.to_string());
755 let _ = crate::agent::settings::save_field("defaultThinkingLevel", next);
756 app.provider.set_reasoning_effort(Some(next));
758 app.status_text = Some(format!("Thinking level: {}", next));
759}
760
761fn handle_model_cycle(app: &mut App, dir: isize) {
763 let n = app.available_models.len();
764 if n == 0 {
765 app.status_text = Some("No models available".into());
766 return;
767 }
768
769 let current_idx = app.available_models.iter().position(|m| m == &app.model);
770
771 let next_idx = match current_idx {
772 Some(idx) => (idx as isize + dir).rem_euclid(n as isize) as usize,
773 None => 0,
774 };
775
776 app.model = app.available_models[next_idx].clone();
777 app.footer.borrow_mut().set_model(&app.model);
778 app.footer.borrow_mut().set_model_supports_reasoning(true);
780 app.status_text = Some(format!("Model: {}", app.model));
781}
782
783fn handle_tools_expand(app: &mut App) {
787 app.tools_expanded = !app.tools_expanded;
788 app.collapse_tool_output = !app.tools_expanded;
789
790 app.header.borrow_mut().set_expanded(app.tools_expanded);
793
794 let mut chat = app.chat_container.inner.borrow_mut();
796 for child in chat.children_mut().iter_mut() {
797 child.set_expanded(app.tools_expanded);
798 }
799 drop(chat);
800
801 app.settings.collapse_tool_output = Some(app.collapse_tool_output);
802 let _ = crate::agent::settings::save_field("collapseToolOutput", app.collapse_tool_output);
803 chat_add(
804 app,
805 Box::new(InfoMessageComponent::new(if app.tools_expanded {
806 "Tool output: expanded".to_string()
807 } else {
808 "Tool output: collapsed".to_string()
809 })),
810 );
811}
812
813fn handle_editor_external(app: &mut App) {
815 let editor_cmd = std::env::var("VISUAL")
816 .or_else(|_| std::env::var("EDITOR"))
817 .unwrap_or_default();
818
819 if editor_cmd.is_empty() {
820 app.status_text = Some("No editor configured. Set $VISUAL or $EDITOR.".into());
821 return;
822 }
823
824 let tmp_dir = std::env::temp_dir();
825 let tmp_file = tmp_dir.join(format!(
826 "rab-editor-{}.md",
827 std::time::SystemTime::now()
828 .duration_since(std::time::UNIX_EPOCH)
829 .map(|d| d.as_nanos())
830 .unwrap_or(0)
831 ));
832
833 let current_text = app.editor.borrow().editor.get_text();
834 if let Err(e) = std::fs::write(&tmp_file, ¤t_text) {
835 app.status_text = Some(format!("Failed to write temp file: {}", e));
836 return;
837 }
838
839 let parts: Vec<&str> = editor_cmd.split(' ').collect();
841 let (editor, args) = parts.split_first().unwrap_or((&"", &[]));
842
843 app.status_text = Some(format!("Opening {} ...", editor_cmd));
846
847 let status = std::process::Command::new(editor)
849 .args(args)
850 .arg(&tmp_file)
851 .status();
852
853 match status {
854 Ok(status) if status.success() => {
855 if let Ok(new_content) = std::fs::read_to_string(&tmp_file) {
856 let trimmed = new_content.trim_end_matches('\n').to_string();
857 app.editor.borrow_mut().editor.set_text(&trimmed);
858 app.editor.borrow_mut().check_autocomplete();
859 }
860 let _ = std::fs::remove_file(&tmp_file);
861 app.status_text = Some("Editor closed".into());
862 }
863 Ok(_) => {
864 let _ = std::fs::remove_file(&tmp_file);
865 app.status_text = Some("Editor exited with non-zero status".into());
866 }
867 Err(e) => {
868 let _ = std::fs::remove_file(&tmp_file);
869 app.status_text = Some(format!("Failed to launch editor: {}", e));
870 }
871 }
872}
873
874fn handle_follow_up(app: &mut App, text: String) {
877 if app.is_streaming {
878 app.follow_up_queue
879 .lock()
880 .unwrap()
881 .enqueue(AgentMessage::user(text));
882 app.status_text = Some("Message queued - will send when agent finishes".into());
883 } else {
884 submit_message(app, text);
886 }
887}
888
889fn handle_dequeue(app: &mut App) {
892 let mut queue = app.follow_up_queue.lock().unwrap();
893 if queue.is_empty() {
894 app.status_text = Some("No queued messages to restore".into());
895 return;
896 }
897
898 let count = queue.len();
899 let all = queue.drain_all();
900 let restored: Vec<String> = all.iter().map(|m| m.content.clone()).collect();
901 let text = restored.join("\n\n");
902 app.editor.borrow_mut().editor.set_text(&text);
903 app.editor.borrow_mut().check_autocomplete();
904 app.status_text = Some(format!(
905 "Restored {} queued message{}",
906 count,
907 if count == 1 { "" } else { "s" }
908 ));
909}
910
911fn handle_compact_toggle(app: &mut App) {
913 app.auto_compact = !app.auto_compact;
914 app.footer.borrow_mut().set_auto_compact(app.auto_compact);
915 app.status_text = Some(if app.auto_compact {
916 "Auto-compact: on".into()
917 } else {
918 "Auto-compact: off".into()
919 });
920}
921
922fn interrupt_streaming(app: &mut App) {
924 if let Some(handle) = app.agent_abort.take() {
925 handle.abort();
926 }
927 app.is_streaming = false;
928 app.working.stop();
929 app.footer.borrow_mut().set_streaming(false);
930
931 if let Ok(mut follow_up) = app.follow_up_queue.try_lock() {
934 if !follow_up.is_empty() {
935 let all = follow_up.drain_all();
936 let text: Vec<String> = all.iter().map(|m| m.content.clone()).collect();
937 app.editor.borrow_mut().editor.set_text(&text.join("\n\n"));
938 app.queued_section.set_lines(vec![]);
939 }
940 drop(follow_up);
941 }
942
943 if let Ok(mut steering) = app.steering_queue.try_lock() {
944 steering.clear();
945 }
946
947 app.status_text = Some("Interrupted".into());
948}
949
950fn open_model_selector(app: &mut App, tui: &mut TUI) {
952 let models = app.available_models.clone();
953 let current = app.model.clone();
954 let selector = ModelSelector::new(models, ¤t, &app.theme);
955 tui.show_overlay(Box::new(selector), Default::default());
956}
957
958fn show_help_overlay(app: &mut App, tui: &mut TUI) {
959 let mut overlay = crate::agent::ui::help::HelpOverlay::new(&app.theme);
960 overlay.set_commands(app.commands.clone());
961 tui.show_overlay(Box::new(overlay), Default::default());
962}
963
964fn submit_message(app: &mut App, message: String) {
968 app.scroll_offset = 0;
969 let trimmed = message.trim().to_string();
970
971 if trimmed.is_empty() {
973 return;
974 }
975
976 if trimmed.starts_with("/skill:") {
978 let expanded = crate::agent::skills::expand_skill_command(&trimmed, &app.skills);
979 chat_add(
980 app,
981 std::boxed::Box::new(crate::agent::ui::components::UserMessageComponent::new(
982 &expanded,
983 )),
984 );
985 app.messages.push(DisplayMsg::User(expanded.clone()));
986 if app.is_streaming {
987 app.steering_queue
988 .lock()
989 .unwrap()
990 .enqueue(AgentMessage::user(expanded));
991 return;
992 }
993 start_agent_loop(app, expanded);
994 return;
995 }
996
997 if trimmed.starts_with('/') {
999 handle_slash_command(app, &trimmed);
1001 return;
1002 }
1003
1004 if let Some((cmd, _exclude)) = parse_bang_command(&trimmed) {
1006 handle_bang_command(app, cmd);
1007 return;
1008 }
1009
1010 chat_add(
1013 app,
1014 std::boxed::Box::new(crate::agent::ui::components::UserMessageComponent::new(
1015 &trimmed,
1016 )),
1017 );
1018 app.messages.push(DisplayMsg::User(trimmed.clone()));
1019
1020 if app.is_streaming {
1021 app.steering_queue
1023 .lock()
1024 .unwrap()
1025 .enqueue(AgentMessage::user(trimmed));
1026 return;
1027 }
1028
1029 start_agent_loop(app, trimmed);
1030}
1031
1032fn start_agent_loop(app: &mut App, message: String) {
1034 let provider = Arc::clone(&app.provider);
1035 let model = app.model.clone();
1036 let system_prompt = app.system_prompt.clone();
1037 let tools = collect_tool_defs(app);
1038 let tx = app.event_tx.clone();
1039 let history = app.conversation.clone();
1040 let agent_tools = Arc::clone(&app.agent_tools);
1041 let extensions = Arc::clone(&app.extensions);
1042 let tool_execution = app.tool_execution;
1043 let steering_queue = Arc::clone(&app.steering_queue);
1044 let follow_up_queue = Arc::clone(&app.follow_up_queue);
1045
1046 app.is_streaming = true;
1047 app.working.start();
1048 app.footer.borrow_mut().set_streaming(true);
1049 app.pending_text = None;
1050 app.pending_thinking = None;
1051
1052 let handle = tokio::spawn(async move {
1055 struct Guard<'a> {
1057 tx: &'a mpsc::UnboundedSender<AgentEvent>,
1058 sent: bool,
1059 }
1060 impl Drop for Guard<'_> {
1061 fn drop(&mut self) {
1062 if !self.sent {
1063 let _ = self.tx.send(AgentEvent::AgentEnd { messages: vec![] });
1064 }
1065 }
1066 }
1067 let mut guard = Guard {
1068 tx: &tx,
1069 sent: false,
1070 };
1071
1072 let config = LoopConfig {
1073 model: model.clone(),
1074 system_prompt,
1075 tools,
1076 agent_tools: &agent_tools,
1077 extensions: &extensions,
1078 tool_execution,
1079 steering_queue: Some(&*steering_queue),
1080 follow_up_queue: Some(&*follow_up_queue),
1081 transform_context: None,
1082 prepare_next_turn: None,
1083 should_stop_after_turn: None,
1084 };
1085
1086 let mut emit = |event: AgentEvent| {
1087 let _ = tx.send(event);
1088 };
1089
1090 let prompt = AgentMessage::user(message);
1091 match run_agent_loop(vec![prompt], history, &config, &*provider, &mut emit).await {
1092 Ok(_) => {
1093 guard.sent = true;
1095 }
1096 Err(e) => {
1097 emit(AgentEvent::ToolResult {
1098 id: String::new(),
1099 name: "error".into(),
1100 content: format!("Error: {:#}", e),
1101 compact: None,
1102 is_error: true,
1103 });
1104 emit(AgentEvent::AgentEnd { messages: vec![] });
1105 guard.sent = true;
1106 }
1107 }
1108 });
1109 app.agent_abort = Some(handle.abort_handle());
1110}
1111
1112fn handle_slash_command(app: &mut App, input: &str) {
1114 let (cols, _rows) = crossterm::terminal::size().unwrap_or((80, 24));
1116 let (cmd_name, args) = match input.split_once(' ') {
1117 Some((cmd, rest)) => (cmd.trim_start_matches('/'), rest),
1118 None => (input.trim_start_matches('/'), ""),
1119 };
1120
1121 if cmd_name == "model" || cmd_name.starts_with("mod") && args.is_empty() {
1123 let models = app.available_models.clone();
1124 let current = app.model.clone();
1125 let selector = ModelSelector::new(models, ¤t, &app.theme);
1126 let lines = selector.render(cols as usize);
1127 for line in lines {
1130 app.messages.push(DisplayMsg::Info(line));
1131 }
1132 return;
1133 }
1134
1135 if cmd_name == "help" || cmd_name == "h" {
1137 app.messages.push(DisplayMsg::Info(
1138 "Help: Press F1 for keyboard shortcuts.".into(),
1139 ));
1140 return;
1141 }
1142
1143 if cmd_name == "quit" || cmd_name == "q" {
1145 app.should_quit = true;
1146 return;
1147 }
1148
1149 app.status_text = Some(format!(
1151 "Unknown command: /{}. Type /help for available commands.",
1152 cmd_name
1153 ));
1154}
1155
1156fn handle_bang_command(app: &mut App, command: String) {
1160 let cwd = app.cwd.clone();
1161 let tx = app.event_tx.clone();
1162
1163 let bash_comp = Rc::new(RefCell::new(
1165 crate::agent::ui::components::BashExecution::new(&command),
1166 ));
1167 app.bash_component = Some(Rc::downgrade(&bash_comp));
1168 chat_add(
1169 app,
1170 std::boxed::Box::new(crate::tui::components::RcRefCellComponent(bash_comp)),
1171 );
1172 app.messages
1173 .push(DisplayMsg::User(format!("! {}", command)));
1174
1175 app.is_streaming = true;
1176 app.working.start();
1177 app.footer.borrow_mut().set_streaming(true);
1178
1179 let handle = tokio::spawn(async move {
1180 struct Guard<'a> {
1181 tx: &'a mpsc::UnboundedSender<AgentEvent>,
1182 sent: bool,
1183 }
1184 impl Drop for Guard<'_> {
1185 fn drop(&mut self) {
1186 if !self.sent {
1187 let _ = self.tx.send(AgentEvent::AgentEnd { messages: vec![] });
1188 }
1189 }
1190 }
1191 let mut guard = Guard {
1192 tx: &tx,
1193 sent: false,
1194 };
1195
1196 let started = std::time::Instant::now();
1197 let mut child = match tokio::process::Command::new("sh")
1198 .arg("-c")
1199 .arg(&command)
1200 .current_dir(&cwd)
1201 .stdout(std::process::Stdio::piped())
1202 .stderr(std::process::Stdio::piped())
1203 .spawn()
1204 {
1205 Ok(c) => c,
1206 Err(e) => {
1207 let _ = tx.send(AgentEvent::ToolProgress {
1208 content: format!("Failed to spawn: {}", e),
1209 is_error: true,
1210 });
1211 let _ = tx.send(AgentEvent::ToolResult {
1212 id: String::new(),
1213 name: "bash".into(),
1214 content: format!("Failed to execute: {:#}", e),
1215 compact: None,
1216 is_error: true,
1217 });
1218 guard.sent = true;
1219 let _ = tx.send(AgentEvent::AgentEnd { messages: vec![] });
1220 return;
1221 }
1222 };
1223
1224 let mut all_output = String::new();
1225 use tokio::io::AsyncReadExt;
1227 let mut stdio = child.stdout.take().unwrap();
1228 let mut stderr = child.stderr.take().unwrap();
1229 let mut buf1 = [0u8; 4096];
1230 let mut buf2 = [0u8; 4096];
1231 let mut stdout_done = false;
1232 let mut stderr_done = false;
1233
1234 loop {
1235 tokio::select! {
1236 result = stdio.read(&mut buf1), if !stdout_done => {
1237 match result {
1238 Ok(0) => stdout_done = true,
1239 Ok(n) => {
1240 if let Ok(text) = std::str::from_utf8(&buf1[..n]) {
1241 all_output.push_str(text);
1242 let _ = tx.send(AgentEvent::ToolProgress {
1243 content: text.to_string(),
1244 is_error: false,
1245 });
1246 }
1247 }
1248 Err(_) => stdout_done = true,
1249 }
1250 }
1251 result = stderr.read(&mut buf2), if !stderr_done => {
1252 match result {
1253 Ok(0) => stderr_done = true,
1254 Ok(n) => {
1255 if let Ok(text) = std::str::from_utf8(&buf2[..n]) {
1256 all_output.push_str(text);
1257 let _ = tx.send(AgentEvent::ToolProgress {
1258 content: text.to_string(),
1259 is_error: false,
1260 });
1261 }
1262 }
1263 Err(_) => stderr_done = true,
1264 }
1265 }
1266 }
1267 if stdout_done && stderr_done {
1268 break;
1269 }
1270 }
1271
1272 let status = child.wait().await;
1274 let elapsed = started.elapsed();
1275 let is_error = match &status {
1276 Ok(s) => !s.success(),
1277 Err(_) => true,
1278 };
1279 let result = if all_output.trim().is_empty() {
1280 "(no output)".to_string()
1281 } else {
1282 all_output.trim().to_string()
1283 };
1284
1285 let _ = tx.send(AgentEvent::ToolResult {
1286 id: String::new(),
1287 name: "bash".into(),
1288 content: format!(
1289 "$ {}\n\n{}\n\n[{}s]",
1290 command,
1291 result,
1292 elapsed.as_secs_f64()
1293 ),
1294 compact: None,
1295 is_error,
1296 });
1297 guard.sent = true;
1298 let _ = tx.send(AgentEvent::AgentEnd { messages: vec![] });
1299 });
1300 app.agent_abort = Some(handle.abort_handle());
1301}
1302
1303pub fn chat_add(app: &mut App, component: std::boxed::Box<dyn Component>) {
1307 let mut chat = app.chat_container.inner.borrow_mut();
1308 if !chat.children().is_empty() {
1309 chat.add_child(std::boxed::Box::new(Spacer::new(1)));
1310 }
1311 chat.add_child(component);
1312}
1313
1314#[allow(dead_code)]
1316fn format_tool_call_header(name: &str, args: &serde_json::Value) -> String {
1317 let theme = crate::agent::ui::theme::current_theme();
1318 match name {
1319 "bash" => {
1320 let cmd = args
1321 .get("command")
1322 .and_then(|v| v.as_str())
1323 .unwrap_or("...");
1324 let timeout = args.get("timeout").and_then(|v| v.as_i64());
1325 let timeout_suffix = timeout
1326 .map(|t| theme.fg("muted", &format!(" (timeout {}s)", t)))
1327 .unwrap_or_default();
1328 format!(
1329 "{}{}",
1330 theme.fg("toolTitle", &theme.bold(&format!("$ {}", cmd))),
1331 timeout_suffix
1332 )
1333 }
1334 "read" => {
1335 let path = args
1336 .get("file_path")
1337 .or_else(|| args.get("path"))
1338 .and_then(|v| v.as_str())
1339 .unwrap_or("");
1340 let offset = args.get("offset").and_then(|v| v.as_u64()).unwrap_or(0);
1341 let limit = args.get("limit").and_then(|v| v.as_u64());
1342 let short = if let Ok(home) = std::env::var("HOME") {
1343 path.replacen(&home, "~", 1)
1344 } else {
1345 path.to_string()
1346 };
1347 let path_disp = if short.is_empty() {
1348 String::new()
1349 } else {
1350 theme.fg("accent", &short)
1351 };
1352 let range = if offset > 0 || limit.is_some() {
1353 let start = if offset > 0 { offset } else { 1 };
1354 let range_str = match limit {
1355 Some(l) => format!(":{}-{}", start, start + l - 1),
1356 None => format!(":{}", start),
1357 };
1358 theme.fg("warning", &range_str)
1359 } else {
1360 String::new()
1361 };
1362 let is_docs = path.contains("docs/") || path.ends_with("README.md");
1363 let is_resource = path.ends_with("AGENTS.md") || path.ends_with("CLAUDE.md");
1364 if is_docs {
1365 format!(
1366 "{} {}{}",
1367 theme.fg("toolTitle", &theme.bold("read docs")),
1368 path_disp,
1369 range
1370 )
1371 } else if is_resource {
1372 format!(
1373 "{} {}{}",
1374 theme.fg("toolTitle", &theme.bold("read resource")),
1375 path_disp,
1376 range
1377 )
1378 } else {
1379 format!(
1380 "{} {}{}",
1381 theme.fg("toolTitle", &theme.bold("read")),
1382 path_disp,
1383 range
1384 )
1385 }
1386 }
1387 "write" => {
1388 let path = args
1389 .get("file_path")
1390 .or_else(|| args.get("path"))
1391 .and_then(|v| v.as_str())
1392 .unwrap_or("");
1393 let short = if let Ok(home) = std::env::var("HOME") {
1394 path.replacen(&home, "~", 1)
1395 } else {
1396 path.to_string()
1397 };
1398 format!(
1399 "{} {}",
1400 theme.fg("toolTitle", &theme.bold("write")),
1401 theme.fg("accent", &short)
1402 )
1403 }
1404 "edit" => {
1405 let path = args
1406 .get("file_path")
1407 .or_else(|| args.get("path"))
1408 .and_then(|v| v.as_str())
1409 .unwrap_or("");
1410 let short = if let Ok(home) = std::env::var("HOME") {
1411 path.replacen(&home, "~", 1)
1412 } else {
1413 path.to_string()
1414 };
1415 format!(
1416 "{} {}",
1417 theme.fg("toolTitle", &theme.bold("edit")),
1418 theme.fg("accent", &short)
1419 )
1420 }
1421 "ls" => {
1422 let path = args
1423 .get("file_path")
1424 .or_else(|| args.get("path"))
1425 .and_then(|v| v.as_str())
1426 .unwrap_or(".");
1427 let limit = args.get("limit").and_then(|v| v.as_u64());
1428 let short = if let Ok(home) = std::env::var("HOME") {
1429 path.replacen(&home, "~", 1)
1430 } else {
1431 path.to_string()
1432 };
1433 let limit_str = limit.map(|l| format!(" (limit {})", l)).unwrap_or_default();
1434 format!(
1435 "{} {}{}",
1436 theme.fg("toolTitle", &theme.bold("ls")),
1437 theme.fg("accent", &short),
1438 limit_str
1439 )
1440 }
1441 _ => {
1442 let args_str = serde_json::to_string(args).unwrap_or_default();
1443 let suffix = if args_str.is_empty() || args_str == "{}" {
1444 String::new()
1445 } else {
1446 format!(" {}", theme.fg("muted", &args_str))
1447 };
1448 format!("{}{}", theme.fg("toolTitle", &theme.bold(name)), suffix)
1449 }
1450 }
1451}
1452
1453fn handle_agent_event(app: &mut App, event: AgentEvent) {
1455 match event {
1456 AgentEvent::AgentStart => {
1457 app.is_streaming = true;
1458 app.pending_text = None;
1459 app.pending_thinking = None;
1460
1461 app.refresh_git_branch();
1463 }
1464 AgentEvent::TurnStart => {}
1465 AgentEvent::TextDelta { delta } => {
1466 if let Some(weak) = app.streaming_component.as_ref().and_then(|w| w.upgrade()) {
1468 weak.borrow_mut().append_text(&delta);
1469 } else {
1470 use crate::tui::components::rc_ref_cell_component::RcRefCellComponent;
1472 let comp = Rc::new(RefCell::new(
1473 crate::agent::ui::components::AssistantMessageComponent::new(&delta),
1474 ));
1475 if app.hide_thinking {
1476 comp.borrow_mut().set_hide_thinking(true);
1477 }
1478 app.streaming_component = Some(Rc::downgrade(&comp));
1479 chat_add(app, std::boxed::Box::new(RcRefCellComponent(comp)));
1480 }
1481 }
1482 AgentEvent::ThinkingDelta { delta } => {
1483 if let Some(weak) = app.streaming_component.as_ref().and_then(|w| w.upgrade()) {
1485 weak.borrow_mut()
1486 .add_thinking(&delta, app.thinking_level.clone());
1487 } else {
1488 use crate::tui::components::rc_ref_cell_component::RcRefCellComponent;
1490 let mut comp = crate::agent::ui::components::AssistantMessageComponent::new("");
1491 comp.add_thinking(&delta, app.thinking_level.clone());
1492 if app.hide_thinking {
1493 comp.set_hide_thinking(true);
1494 }
1495 let comp = Rc::new(RefCell::new(comp));
1496 app.streaming_component = Some(Rc::downgrade(&comp));
1497 chat_add(app, std::boxed::Box::new(RcRefCellComponent(comp)));
1498 }
1499 }
1500 AgentEvent::ToolCall { id, name, args, .. } => {
1501 flush_all(app);
1502 app.streaming_component = None;
1505 let renderer = app
1507 .agent_tools
1508 .iter()
1509 .find(|t| t.name() == name)
1510 .and_then(|t| t.renderer());
1511
1512 let started_at = std::time::Instant::now();
1514 let comp = if name == "bash" {
1515 let mut tool = crate::agent::ui::components::ToolExecComponent::new(
1516 &name,
1517 renderer,
1518 args.clone(),
1519 );
1520 tool.set_started_at(std::time::Instant::now());
1521 Rc::new(RefCell::new(tool))
1522 } else if name == "read" {
1523 let path = args
1524 .get("file_path")
1525 .or_else(|| args.get("path"))
1526 .and_then(|v| v.as_str())
1527 .unwrap_or("");
1528 let mut comp = crate::agent::ui::components::ToolExecComponent::new(
1529 &name,
1530 app.agent_tools
1531 .iter()
1532 .find(|t| t.name() == name)
1533 .and_then(|t| t.renderer()),
1534 args.clone(),
1535 );
1536 comp.set_file_path(path.to_string());
1537 comp.set_started_at(std::time::Instant::now());
1538 Rc::new(RefCell::new(comp))
1539 } else {
1540 let mut tool = crate::agent::ui::components::ToolExecComponent::new(
1541 &name,
1542 renderer,
1543 args.clone(),
1544 );
1545 tool.set_started_at(std::time::Instant::now());
1546 Rc::new(RefCell::new(tool))
1547 };
1548 app.pending_tools.insert(id.clone(), Rc::downgrade(&comp));
1549 app.tool_call_start_times.insert(id.clone(), started_at);
1550 chat_add(
1551 app,
1552 std::boxed::Box::new(crate::agent::ui::components::RcToolExec(comp)),
1553 );
1554
1555 let args_str = serde_json::to_string(&args).unwrap_or_default();
1557 app.messages.push(DisplayMsg::ToolCall {
1558 name,
1559 args: args_str,
1560 });
1561 }
1562 AgentEvent::ToolCallArgsUpdate { id, args } => {
1563 if let Some(weak) = app.pending_tools.get(&id)
1565 && let Some(comp) = weak.upgrade()
1566 {
1567 comp.borrow_mut().set_args(args);
1568 }
1569 }
1570 AgentEvent::ToolResult {
1571 content,
1572 compact: _,
1573 is_error,
1574 name,
1575 id,
1576 } => {
1577 if let Some(weak) = app.pending_tools.remove(&id) {
1578 if let Some(comp) = weak.upgrade() {
1580 if name == "bash" {
1581 let comp = weak.upgrade().expect("weak still valid");
1583 let mut comp = comp.borrow_mut();
1584 if let Some(start) = app.tool_call_start_times.remove(&id) {
1585 comp.set_final_duration(start.elapsed().as_secs_f64());
1586 }
1587 if is_error {
1588 if content.contains("aborted") || content.contains("cancelled") {
1589 comp.set_cancelled(true);
1590 } else if let Some(code) = extract_exit_code(&content) {
1591 comp.set_exit_code(code);
1592 }
1593 } else {
1594 comp.set_exit_code(0);
1595 }
1596 if content.contains("Full output:")
1597 && let Some(path) = extract_full_output_path(&content)
1598 {
1599 comp.set_truncated(true, Some(path));
1600 }
1601 comp.set_result(&content, is_error);
1602 } else {
1603 comp.borrow_mut().set_result(&content, is_error);
1604 };
1605 }
1606 } else if name == "bash" {
1607 if let Some(weak) = app.bash_component.as_ref().and_then(|w| w.upgrade()) {
1610 let mut bash = weak.borrow_mut();
1611 let output_lines: Vec<&str> = content.lines().skip(2).collect();
1612 let output = output_lines.join("\n");
1613 if !output.is_empty() {
1614 bash.append_chunk(&output);
1615 }
1616 bash.set_duration_from_content(&content);
1617 bash.set_complete(if is_error { 1 } else { 0 });
1618 drop(bash);
1619 app.bash_component = None;
1620 } else {
1621 let cmd = content
1623 .lines()
1624 .next()
1625 .unwrap_or("")
1626 .trim_start_matches("$ ")
1627 .to_string();
1628 let mut bash = crate::agent::ui::components::BashExecution::new(&cmd);
1629 let output_lines: Vec<&str> = content.lines().skip(2).collect();
1630 let output = output_lines.join("\n");
1631 if !output.is_empty() {
1632 bash.append_chunk(&output);
1633 }
1634 bash.set_duration_from_content(&content);
1635 bash.set_complete(if is_error { 1 } else { 0 });
1636 chat_add(app, std::boxed::Box::new(bash));
1637 }
1638 } else {
1639 chat_add(
1642 app,
1643 std::boxed::Box::new(crate::agent::ui::components::ToolResultComponent::new(
1644 &content, is_error,
1645 )),
1646 );
1647 }
1648 app.messages.push(DisplayMsg::ToolResult {
1650 content,
1651 compact: None,
1652 is_error,
1653 });
1654 }
1655 AgentEvent::ToolProgress { content, is_error } => {
1656 if let Some(weak) = app.bash_component.as_ref().and_then(|w| w.upgrade()) {
1658 let mut bash = weak.borrow_mut();
1659 bash.append_chunk(&content);
1660 if is_error {
1661 bash.set_error(content);
1662 }
1663 }
1664 }
1665 AgentEvent::UserMessage { content } => {
1666 chat_add(
1668 app,
1669 std::boxed::Box::new(crate::agent::ui::components::UserMessageComponent::new(
1670 &content,
1671 )),
1672 );
1673 app.messages.push(DisplayMsg::User(content));
1674 }
1675 AgentEvent::Aborted { reason } => {
1676 if let Some(weak) = app.streaming_component.as_ref().and_then(|w| w.upgrade()) {
1678 let err_text = format!("\n\n**Error:** {}", reason);
1679 weak.borrow_mut().append_text(&err_text);
1680 }
1681 }
1682 AgentEvent::TurnEnd => {
1683 flush_all(app);
1684 app.streaming_component = None;
1686 }
1687 AgentEvent::AgentEnd { ref messages } => {
1688 flush_all(app);
1689 app.streaming_component = None;
1690 app.bash_component = None;
1691 app.is_streaming = false;
1692 app.working.stop();
1693 app.footer.borrow_mut().set_streaming(false);
1694 app.agent_abort = None;
1695
1696 if let Some(ref mut session) = app.session {
1699 for msg in messages {
1700 session.append_message(msg);
1701 }
1702 }
1703 for msg in messages {
1705 if !app.conversation.iter().any(|m| m.id == msg.id) {
1706 app.conversation.push(msg.clone());
1707 }
1708 }
1709 if let Some(last) = messages.iter().rev().find(|m| m.usage.is_some()) {
1710 app.last_usage = last.usage.clone();
1711 app.footer
1712 .borrow_mut()
1713 .accumulate_usage(last.usage.as_ref().unwrap());
1714 }
1715
1716 }
1721 }
1722}
1723
1724fn flush_text(app: &mut App) {
1725 if let Some(text) = app.pending_text.take()
1726 && !text.is_empty()
1727 {
1728 let mut comp = crate::agent::ui::components::AssistantMessageComponent::new(&text);
1730 if app.hide_thinking {
1731 comp.set_hide_thinking(true);
1732 }
1733 chat_add(app, std::boxed::Box::new(comp));
1734 app.messages.push(DisplayMsg::AssistantText(text));
1736 }
1737}
1738
1739fn flush_thinking(app: &mut App) {
1740 if let Some(text) = app.pending_thinking.take()
1741 && !text.is_empty()
1742 {
1743 let mut thinking = crate::agent::ui::components::AssistantMessageComponent::new("");
1745 thinking.add_thinking(&text, app.thinking_level.clone());
1746 if app.hide_thinking {
1747 thinking.set_hide_thinking(true);
1748 }
1749 chat_add(app, std::boxed::Box::new(thinking));
1750 app.messages.push(DisplayMsg::Thinking {
1752 text,
1753 level: app.thinking_level.clone(),
1754 });
1755 }
1756}
1757
1758fn flush_all(app: &mut App) {
1759 flush_text(app);
1760 flush_thinking(app);
1761}
1762
1763fn collect_tool_defs(app: &App) -> Vec<ToolDef> {
1765 let mut defs = Vec::new();
1766 for tool in app.agent_tools.iter() {
1767 if !defs.iter().any(|d: &ToolDef| d.name == tool.name()) {
1768 defs.push(ToolDef {
1769 name: tool.name().to_string(),
1770 description: tool.description().to_string(),
1771 parameters: tool.parameters(),
1772 });
1773 }
1774 }
1775 defs
1776}
1777
1778fn parse_bang_command(input: &str) -> Option<(String, bool)> {
1780 if let Some(rest) = input.strip_prefix("!!") {
1781 let cmd = rest.trim();
1782 if cmd.is_empty() {
1783 None
1784 } else {
1785 Some((cmd.to_string(), true))
1786 }
1787 } else if let Some(rest) = input.strip_prefix('!') {
1788 let cmd = rest.trim();
1789 if cmd.is_empty() {
1790 None
1791 } else {
1792 Some((cmd.to_string(), false))
1793 }
1794 } else {
1795 None
1796 }
1797}
1798
1799fn extract_exit_code(content: &str) -> Option<i32> {
1802 if let Some(pos) = content.rfind("exited with code ") {
1803 let num_start = pos + "exited with code ".len();
1804 let rest = &content[num_start..];
1805 let num_str: String = rest
1806 .chars()
1807 .take_while(|c| c.is_ascii_digit() || *c == '-')
1808 .collect();
1809 if !num_str.is_empty() {
1810 return num_str.parse().ok();
1811 }
1812 }
1813 None
1814}
1815
1816fn extract_full_output_path(content: &str) -> Option<String> {
1819 if let Some(pos) = content.rfind("Full output: ") {
1820 let path_start = pos + "Full output: ".len();
1821 let rest = &content[path_start..];
1822 let path: String = rest.chars().take_while(|c| !c.is_whitespace()).collect();
1823 if !path.is_empty() {
1824 return Some(path);
1825 }
1826 }
1827 None
1828}
1829
1830#[cfg(test)]
1831mod tests {
1832 use super::*;
1833 use crate::agent::provider::StreamEvent;
1834 use crate::agent::types::AgentMessage;
1835 use crate::agent::ui::messages::render_messages;
1836 use async_trait::async_trait;
1837 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1838 use futures::Stream;
1839 use std::pin::Pin;
1840 use tempfile::tempdir;
1841
1842 struct MockProvider;
1843 #[async_trait]
1844 impl Provider for MockProvider {
1845 async fn stream(
1846 &self,
1847 _model: &str,
1848 _system: &str,
1849 _msgs: &[AgentMessage],
1850 _tools: &[ToolDef],
1851 ) -> anyhow::Result<Pin<Box<dyn Stream<Item = StreamEvent> + Send>>> {
1852 unimplemented!()
1853 }
1854 }
1855
1856 #[test]
1857 fn test_compose_ui_stable_line_count() {
1858 let tmp = tempdir().unwrap();
1859 let cwd = tmp.path().to_path_buf();
1860 let session = SessionManager::in_memory(&cwd);
1861
1862 let config = AppConfig {
1863 model: "deepseek-v4-flash".into(),
1864 system_prompt: String::new(),
1865 tools: vec![],
1866 agent_tools: vec![],
1867 extensions: vec![],
1868 provider: Box::new(MockProvider),
1869 cwd: cwd.clone(),
1870 thinking_level: None,
1871 git_branch: None,
1872 available_models: vec![],
1873 hide_thinking: true,
1874 collapse_tool_output: true,
1875 interactive: true,
1876 settings: crate::agent::settings::Settings::default(),
1877 context_files: vec![],
1878 skills: vec![],
1879 model_supports_reasoning: true,
1880 tool_execution: ToolExecutionMode::Parallel,
1881 };
1882
1883 let mut app = App::new(config, session);
1884 let width = 80;
1885
1886 let before = compose_ui_test(&mut app, width);
1888 let slash = KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE);
1890 app.editor.borrow_mut().editor.handle_input(&slash);
1891 let after = compose_ui_test(&mut app, width);
1893
1894 assert_eq!(
1895 before.len(),
1896 after.len(),
1897 "Line count changed from {} to {}",
1898 before.len(),
1899 after.len()
1900 );
1901
1902 let before_has_top = before.iter().any(|l| l.contains('─'));
1904 let before_has_bottom = before.iter().any(|l| l.contains('─'));
1905 let after_has_top = after.iter().any(|l| l.contains('─'));
1906 let after_has_bottom = after.iter().any(|l| l.contains('─'));
1907
1908 assert!(before_has_top, "Before: missing top border");
1909 assert!(before_has_bottom, "Before: missing bottom border");
1910 assert!(after_has_top, "After: missing top border");
1911 assert!(after_has_bottom, "After: missing bottom border");
1912
1913 for (i, (b, a)) in before.iter().zip(after.iter()).enumerate() {
1915 if b != a {
1916 eprintln!("Changed line {}: '{}' -> '{}'", i, b, a);
1917 }
1918 }
1919 }
1920
1921 fn compose_ui_test(app: &mut App, width: usize) -> Vec<String> {
1922 let theme = &app.theme;
1923 let mut lines = Vec::new();
1924
1925 lines.push(theme.bold(&theme.fg("accent", "rab")));
1928 lines.push(String::new());
1929
1930 let rendered = render_messages(
1931 &app.messages,
1932 width,
1933 app.hide_thinking,
1934 app.collapse_tool_output,
1935 theme,
1936 );
1937
1938 let total = rendered.len();
1940 let scroll = app.scroll_offset.min(total.saturating_sub(1));
1941 let visible = if scroll > 0 {
1942 let indicator = theme.fg("dim", &format!(" ↑ {} more", scroll));
1943 lines.push(crate::agent::ui::messages::pad_to_width(&indicator, width));
1944 &rendered[scroll..]
1945 } else {
1946 &rendered[..]
1947 };
1948 lines.extend(visible.iter().cloned());
1949
1950 if let Some(ref text) = app.pending_text
1952 && !text.is_empty()
1953 {
1954 let inner = width.saturating_sub(2);
1955 for line in text.lines() {
1956 if line.is_empty() {
1957 lines.push(String::new());
1958 } else {
1959 let wrapped = crate::tui::util::wrap_text_with_ansi(line, inner);
1960 for w in wrapped {
1961 let line = format!(" {}", w);
1962 lines.push(crate::agent::ui::messages::pad_to_width(&line, width));
1963 }
1964 }
1965 }
1966 }
1967 if let Some(ref text) = app.pending_thinking
1968 && !text.is_empty()
1969 && !app.hide_thinking
1970 {
1971 let level_color = app
1972 .thinking_level
1973 .as_deref()
1974 .and_then(crate::agent::ui::messages::thinking_level_color)
1975 .unwrap_or("thinking_text");
1976 for line in text.lines() {
1977 let content = format!(" {}", theme.italic(&theme.fg(level_color, line)));
1978 let padded = crate::agent::ui::messages::pad_to_width(&content, width);
1979 lines.push(theme.bg("thinking_bg", &padded));
1980 }
1981 }
1982
1983 let follow_msgs: Vec<String> = {
1985 let q = app.follow_up_queue.lock().unwrap();
1986 if q.is_empty() {
1989 vec![]
1990 } else {
1991 vec![format!("◷ {} follow-up message(s) pending", q.len())]
1993 }
1994 };
1995 for msg in &follow_msgs {
1996 let line = theme.fg("dim", &format!(" {}", msg));
1997 lines.push(crate::agent::ui::messages::pad_to_width(&line, width));
1998 }
1999 if !follow_msgs.is_empty() {
2000 let hint = theme.fg("dim", " ↳ queued");
2001 lines.push(crate::agent::ui::messages::pad_to_width(&hint, width));
2002 }
2003
2004 if !lines.is_empty() && !lines.last().is_none_or(|l| l.trim().is_empty()) {
2005 lines.push(String::new());
2006 }
2007 lines.extend(app.working.render(width));
2008 lines.extend(app.editor.borrow().editor.render(width));
2009 lines.extend(app.footer.borrow().render(width));
2010 lines
2011 }
2012
2013 #[test]
2016 fn test_submit_queues_when_streaming() {
2017 let tmp = tempdir().unwrap();
2018 let cwd = tmp.path().to_path_buf();
2019 let session = SessionManager::in_memory(&cwd);
2020
2021 let config = AppConfig {
2022 model: "deepseek-v4-flash".into(),
2023 system_prompt: String::new(),
2024 tools: vec![],
2025 agent_tools: vec![],
2026 extensions: vec![],
2027 provider: Box::new(MockProvider),
2028 cwd: cwd.clone(),
2029 thinking_level: None,
2030 git_branch: None,
2031 available_models: vec![],
2032 hide_thinking: true,
2033 collapse_tool_output: true,
2034 interactive: true,
2035 settings: crate::agent::settings::Settings::default(),
2036 context_files: vec![],
2037 skills: vec![],
2038 model_supports_reasoning: true,
2039 tool_execution: ToolExecutionMode::Parallel,
2040 };
2041
2042 let mut app = App::new(config, session);
2043
2044 app.is_streaming = true;
2046 app.follow_up_queue.lock().unwrap().clear();
2047
2048 submit_message(&mut app, "hello".into());
2050
2051 assert!(
2052 !app.steering_queue.lock().unwrap().is_empty(),
2053 "Message should be in steering queue when streaming"
2054 );
2055 assert!(
2056 app.is_streaming,
2057 "is_streaming should remain true after queuing"
2058 );
2059 }
2060
2061 #[tokio::test]
2062 async fn test_submit_starts_loop_when_not_streaming() {
2063 let tmp = tempdir().unwrap();
2064 let cwd = tmp.path().to_path_buf();
2065 let session = SessionManager::in_memory(&cwd);
2066
2067 let config = AppConfig {
2068 model: "deepseek-v4-flash".into(),
2069 system_prompt: String::new(),
2070 tools: vec![],
2071 agent_tools: vec![],
2072 extensions: vec![],
2073 provider: Box::new(MockProvider),
2074 cwd: cwd.clone(),
2075 thinking_level: None,
2076 git_branch: None,
2077 available_models: vec![],
2078 hide_thinking: true,
2079 collapse_tool_output: true,
2080 interactive: true,
2081 settings: crate::agent::settings::Settings::default(),
2082 context_files: vec![],
2083 skills: vec![],
2084 model_supports_reasoning: true,
2085 tool_execution: ToolExecutionMode::Parallel,
2086 };
2087
2088 let mut app = App::new(config, session);
2089 app.follow_up_queue.lock().unwrap().clear();
2090
2091 submit_message(&mut app, "hello".into());
2093
2094 assert_eq!(app.messages.len(), 1, "Should have just the user message");
2096 assert!(
2097 matches!(app.messages.last(), Some(DisplayMsg::User(_))),
2098 "Last message should be User"
2099 );
2100 }
2101 #[test]
2102 fn test_compose_ui_shows_queued_messages() {
2103 let tmp = tempdir().unwrap();
2104 let cwd = tmp.path().to_path_buf();
2105 let session = SessionManager::in_memory(&cwd);
2106
2107 let config = AppConfig {
2108 model: "deepseek-v4-flash".into(),
2109 system_prompt: String::new(),
2110 tools: vec![],
2111 agent_tools: vec![],
2112 extensions: vec![],
2113 provider: Box::new(MockProvider),
2114 cwd: cwd.clone(),
2115 thinking_level: None,
2116 git_branch: None,
2117 available_models: vec![],
2118 hide_thinking: true,
2119 collapse_tool_output: true,
2120 interactive: true,
2121 settings: crate::agent::settings::Settings::default(),
2122 context_files: vec![],
2123 skills: vec![],
2124 model_supports_reasoning: true,
2125 tool_execution: ToolExecutionMode::Parallel,
2126 };
2127
2128 let mut app = App::new(config, session);
2129 app.follow_up_queue
2130 .lock()
2131 .unwrap()
2132 .enqueue(AgentMessage::user("queued-msg-1"));
2133 app.follow_up_queue
2134 .lock()
2135 .unwrap()
2136 .enqueue(AgentMessage::user("queued-msg-2"));
2137
2138 let lines = compose_ui_test(&mut app, 80);
2139
2140 let all = lines.join("\n");
2141 assert!(
2142 all.contains("follow-up"),
2143 "Compose UI should show follow-up count"
2144 );
2145 assert!(all.contains("2"), "Compose UI should show count of 2");
2146 }
2147
2148 #[test]
2149 fn test_compose_ui_shows_pending_text() {
2150 let tmp = tempdir().unwrap();
2151 let cwd = tmp.path().to_path_buf();
2152 let session = SessionManager::in_memory(&cwd);
2153
2154 let config = AppConfig {
2155 model: "deepseek-v4-flash".into(),
2156 system_prompt: String::new(),
2157 tools: vec![],
2158 agent_tools: vec![],
2159 extensions: vec![],
2160 provider: Box::new(MockProvider),
2161 cwd: cwd.clone(),
2162 thinking_level: None,
2163 git_branch: None,
2164 available_models: vec![],
2165 hide_thinking: true,
2166 collapse_tool_output: true,
2167 interactive: true,
2168 settings: crate::agent::settings::Settings::default(),
2169 context_files: vec![],
2170 skills: vec![],
2171 model_supports_reasoning: true,
2172 tool_execution: ToolExecutionMode::Parallel,
2173 };
2174
2175 let mut app = App::new(config, session);
2176 app.pending_text = Some("streaming text content".into());
2177
2178 let lines = compose_ui_test(&mut app, 80);
2179 let all = lines.join("\n");
2180 assert!(
2181 all.contains("streaming text"),
2182 "Compose UI should contain pending streaming text"
2183 );
2184 }
2185
2186 #[test]
2187 fn test_compose_ui_shows_pending_thinking() {
2188 let tmp = tempdir().unwrap();
2189 let cwd = tmp.path().to_path_buf();
2190 let session = SessionManager::in_memory(&cwd);
2191
2192 let config = AppConfig {
2193 model: "deepseek-v4-flash".into(),
2194 system_prompt: String::new(),
2195 tools: vec![],
2196 agent_tools: vec![],
2197 extensions: vec![],
2198 provider: Box::new(MockProvider),
2199 cwd: cwd.clone(),
2200 thinking_level: None,
2201 git_branch: None,
2202 available_models: vec![],
2203 hide_thinking: false,
2204 collapse_tool_output: true,
2205 interactive: true,
2206 settings: crate::agent::settings::Settings::default(),
2207 context_files: vec![],
2208 skills: vec![],
2209 model_supports_reasoning: true,
2210 tool_execution: ToolExecutionMode::Parallel,
2211 };
2212
2213 let mut app = App::new(config, session);
2214 app.pending_thinking = Some("thinking content".into());
2215
2216 let lines = compose_ui_test(&mut app, 80);
2217 let all = lines.join("\n");
2218 assert!(
2219 all.contains("thinking content"),
2220 "Compose UI should contain pending thinking text when not hidden"
2221 );
2222 }
2223
2224 #[test]
2225 fn test_pending_thinking_hidden_when_hide_thinking() {
2226 let tmp = tempdir().unwrap();
2227 let cwd = tmp.path().to_path_buf();
2228 let session = SessionManager::in_memory(&cwd);
2229
2230 let config = AppConfig {
2231 model: "deepseek-v4-flash".into(),
2232 system_prompt: String::new(),
2233 tools: vec![],
2234 agent_tools: vec![],
2235 extensions: vec![],
2236 provider: Box::new(MockProvider),
2237 cwd: cwd.clone(),
2238 thinking_level: None,
2239 git_branch: None,
2240 available_models: vec![],
2241 hide_thinking: true,
2242 collapse_tool_output: true,
2243 interactive: true,
2244 settings: crate::agent::settings::Settings::default(),
2245 context_files: vec![],
2246 skills: vec![],
2247 model_supports_reasoning: true,
2248 tool_execution: ToolExecutionMode::Parallel,
2249 };
2250
2251 let mut app = App::new(config, session);
2252 app.pending_thinking = Some("hidden thinking".into());
2253
2254 let lines = compose_ui_test(&mut app, 80);
2255 let all = lines.join("\n");
2256 assert!(
2257 !all.contains("hidden thinking"),
2258 "Compose UI should NOT contain thinking content when hide_thinking is true"
2259 );
2260 }
2261
2262 #[tokio::test]
2263 async fn test_agent_end_leaves_follow_up_queue() {
2264 let tmp = tempdir().unwrap();
2265 let cwd = tmp.path().to_path_buf();
2266 let session = SessionManager::in_memory(&cwd);
2267
2268 let config = AppConfig {
2269 model: "deepseek-v4-flash".into(),
2270 system_prompt: String::new(),
2271 tools: vec![],
2272 agent_tools: vec![],
2273 extensions: vec![],
2274 provider: Box::new(MockProvider),
2275 cwd: cwd.clone(),
2276 thinking_level: None,
2277 git_branch: None,
2278 available_models: vec![],
2279 hide_thinking: true,
2280 collapse_tool_output: true,
2281 interactive: true,
2282 settings: crate::agent::settings::Settings::default(),
2283 context_files: vec![],
2284 skills: vec![],
2285 model_supports_reasoning: true,
2286 tool_execution: ToolExecutionMode::Parallel,
2287 };
2288
2289 let mut app = App::new(config, session);
2290 let msg = AgentMessage::user("next-msg");
2291 app.follow_up_queue.lock().unwrap().enqueue(msg.clone());
2292 app.is_streaming = true;
2293 app.working.start();
2294
2295 handle_agent_event(&mut app, AgentEvent::AgentEnd { messages: vec![] });
2298
2299 assert_eq!(
2300 app.follow_up_queue.lock().unwrap().len(),
2301 1,
2302 "Follow-up queue should NOT be processed by AgentEnd (loop handles it)"
2303 );
2304 }
2305
2306 #[test]
2307 fn test_ctrl_c_interrupt_restores_queued_messages() {
2308 let tmp = tempdir().unwrap();
2309 let cwd = tmp.path().to_path_buf();
2310 let session = SessionManager::in_memory(&cwd);
2311
2312 let config = AppConfig {
2313 model: "deepseek-v4-flash".into(),
2314 system_prompt: String::new(),
2315 tools: vec![],
2316 agent_tools: vec![],
2317 extensions: vec![],
2318 provider: Box::new(MockProvider),
2319 cwd: cwd.clone(),
2320 thinking_level: None,
2321 git_branch: None,
2322 available_models: vec![],
2323 hide_thinking: true,
2324 collapse_tool_output: true,
2325 interactive: true,
2326 settings: crate::agent::settings::Settings::default(),
2327 context_files: vec![],
2328 skills: vec![],
2329 model_supports_reasoning: true,
2330 tool_execution: ToolExecutionMode::Parallel,
2331 };
2332
2333 let mut app = App::new(config, session);
2334 app.follow_up_queue
2335 .lock()
2336 .unwrap()
2337 .enqueue(AgentMessage::user("q1"));
2338 app.follow_up_queue
2339 .lock()
2340 .unwrap()
2341 .enqueue(AgentMessage::user("q2"));
2342 app.is_streaming = true;
2343
2344 let mut test_tui = crate::tui::TUI::new();
2346 handle_input(
2347 &mut app,
2348 &mut test_tui,
2349 &KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
2350 );
2351
2352 assert!(
2353 app.follow_up_queue.lock().unwrap().is_empty(),
2354 "Queued messages should be cleared after interrupt"
2355 );
2356 assert!(
2357 app.editor.borrow().editor.get_text().contains("q1"),
2358 "Editor should contain restored queued messages"
2359 );
2360 assert!(
2361 app.editor.borrow().editor.get_text().contains("q2"),
2362 "Editor should contain both restored queued messages"
2363 );
2364 }
2365
2366 #[test]
2367 fn test_render_messages_pads_assistant_text() {
2368 crate::agent::ui::theme::init_theme(Some("dark"), false);
2369 let theme = crate::agent::ui::theme::current_theme().clone();
2370 let msgs = vec![DisplayMsg::AssistantText("short line".into())];
2371
2372 let width = 60;
2373 let lines = render_messages(&msgs, width, false, false, &theme);
2374
2375 for (i, line) in lines.iter().enumerate() {
2376 let vw = crate::tui::util::visible_width(line);
2377 assert!(
2378 vw <= width,
2379 "Line {} has visible_width {} > width {}: {:?}",
2380 i,
2381 vw,
2382 width,
2383 line
2384 );
2385 if !line.is_empty() {
2387 assert!(
2388 vw >= width.saturating_sub(2),
2389 "Line {} has visible_width {} < width-2 {}: {:?}",
2390 i,
2391 vw,
2392 width.saturating_sub(2),
2393 line
2394 );
2395 }
2396 }
2397 }
2398
2399 #[test]
2400 fn test_queued_messages_rendered_in_compose_ui_line_count_is_stable() {
2401 let tmp = tempdir().unwrap();
2402 let cwd = tmp.path().to_path_buf();
2403 let session = SessionManager::in_memory(&cwd);
2404
2405 let config = AppConfig {
2406 model: "deepseek-v4-flash".into(),
2407 system_prompt: String::new(),
2408 tools: vec![],
2409 agent_tools: vec![],
2410 extensions: vec![],
2411 provider: Box::new(MockProvider),
2412 cwd: cwd.clone(),
2413 thinking_level: None,
2414 git_branch: None,
2415 available_models: vec![],
2416 hide_thinking: true,
2417 collapse_tool_output: true,
2418 interactive: true,
2419 settings: crate::agent::settings::Settings::default(),
2420 context_files: vec![],
2421 skills: vec![],
2422 model_supports_reasoning: true,
2423 tool_execution: ToolExecutionMode::Parallel,
2424 };
2425
2426 let mut app = App::new(config, session);
2427
2428 let before = compose_ui_test(&mut app, 80);
2430
2431 app.follow_up_queue
2433 .lock()
2434 .unwrap()
2435 .enqueue(AgentMessage::user("msg1"));
2436 let after = compose_ui_test(&mut app, 80);
2437
2438 assert!(
2440 after.len() > before.len(),
2441 "Line count should increase when queued messages are present"
2442 );
2443
2444 let after_text = after.join("\n");
2446 assert!(
2447 after_text.contains("follow-up"),
2448 "Output should contain follow-up queue info"
2449 );
2450 }
2451
2452 #[test]
2455 fn test_submit_message_does_not_add_to_conversation() {
2456 let tmp = tempdir().unwrap();
2457 let cwd = tmp.path().to_path_buf();
2458 let session = SessionManager::in_memory(&cwd);
2459
2460 let config = AppConfig {
2461 model: "deepseek-v4-flash".into(),
2462 system_prompt: String::new(),
2463 tools: vec![],
2464 agent_tools: vec![],
2465 extensions: vec![],
2466 provider: Box::new(MockProvider),
2467 cwd: cwd.clone(),
2468 thinking_level: None,
2469 git_branch: None,
2470 available_models: vec![],
2471 hide_thinking: true,
2472 collapse_tool_output: true,
2473 interactive: true,
2474 settings: crate::agent::settings::Settings::default(),
2475 context_files: vec![],
2476 skills: vec![],
2477 model_supports_reasoning: true,
2478 tool_execution: ToolExecutionMode::Parallel,
2479 };
2480
2481 let mut app = App::new(config, session);
2482
2483 app.is_streaming = true;
2485 let initial_len = app.conversation.len();
2486
2487 submit_message(&mut app, "test message".into());
2489
2490 assert_eq!(
2493 app.conversation.len(),
2494 initial_len,
2495 "submit_message must not add to app.conversation (avoids double-send)"
2496 );
2497
2498 assert!(
2500 app.messages
2501 .iter()
2502 .any(|m| matches!(m, DisplayMsg::User(t) if t == "test message")),
2503 "submit_message must add DisplayMsg::User for immediate display"
2504 );
2505 }
2506
2507 #[test]
2508 fn test_agent_end_populates_conversation() {
2509 let tmp = tempdir().unwrap();
2510 let cwd = tmp.path().to_path_buf();
2511 let session = SessionManager::in_memory(&cwd);
2512
2513 let config = AppConfig {
2514 model: "deepseek-v4-flash".into(),
2515 system_prompt: String::new(),
2516 tools: vec![],
2517 agent_tools: vec![],
2518 extensions: vec![],
2519 provider: Box::new(MockProvider),
2520 cwd: cwd.clone(),
2521 thinking_level: None,
2522 git_branch: None,
2523 available_models: vec![],
2524 hide_thinking: true,
2525 collapse_tool_output: true,
2526 interactive: true,
2527 settings: crate::agent::settings::Settings::default(),
2528 context_files: vec![],
2529 skills: vec![],
2530 model_supports_reasoning: true,
2531 tool_execution: ToolExecutionMode::Parallel,
2532 };
2533
2534 let mut app = App::new(config, session);
2535
2536 let user_msg = AgentMessage::user("hello");
2538 let assistant_msg = AgentMessage {
2539 id: uuid::Uuid::new_v4().to_string(),
2540 parent_id: None,
2541 role: crate::agent::types::Role::Assistant,
2542 content: "Hello back".to_string(),
2543 tool_calls: vec![],
2544 tool_call_id: None,
2545 usage: None,
2546 is_error: false,
2547 timestamp: chrono::Utc::now().timestamp_millis(),
2548 };
2549
2550 let agent_messages = vec![user_msg.clone(), assistant_msg.clone()];
2551
2552 handle_agent_event(
2554 &mut app,
2555 AgentEvent::AgentEnd {
2556 messages: agent_messages,
2557 },
2558 );
2559
2560 assert_eq!(
2563 app.conversation.len(),
2564 2,
2565 "conversation should have user + assistant"
2566 );
2567 assert_eq!(app.conversation[0].content, "hello");
2568 assert_eq!(app.conversation[0].role, crate::agent::types::Role::User);
2569 assert_eq!(app.conversation[1].content, "Hello back");
2570 assert_eq!(
2571 app.conversation[1].role,
2572 crate::agent::types::Role::Assistant
2573 );
2574 }
2575
2576 #[test]
2577 fn test_agent_end_no_duplicate_messages() {
2578 let tmp = tempdir().unwrap();
2579 let cwd = tmp.path().to_path_buf();
2580 let session = SessionManager::in_memory(&cwd);
2581
2582 let config = AppConfig {
2583 model: "deepseek-v4-flash".into(),
2584 system_prompt: String::new(),
2585 tools: vec![],
2586 agent_tools: vec![],
2587 extensions: vec![],
2588 provider: Box::new(MockProvider),
2589 cwd: cwd.clone(),
2590 thinking_level: None,
2591 git_branch: None,
2592 available_models: vec![],
2593 hide_thinking: true,
2594 collapse_tool_output: true,
2595 interactive: true,
2596 settings: crate::agent::settings::Settings::default(),
2597 context_files: vec![],
2598 skills: vec![],
2599 model_supports_reasoning: true,
2600 tool_execution: ToolExecutionMode::Parallel,
2601 };
2602
2603 let mut app = App::new(config, session);
2604
2605 let existing = AgentMessage::user("existing");
2607 let existing_id = existing.id.clone();
2608 app.conversation.push(existing);
2609
2610 let dup_msg = AgentMessage {
2612 id: existing_id,
2613 parent_id: None,
2614 role: crate::agent::types::Role::User,
2615 content: "existing".to_string(),
2616 tool_calls: vec![],
2617 tool_call_id: None,
2618 usage: None,
2619 is_error: false,
2620 timestamp: 0,
2621 };
2622
2623 handle_agent_event(
2624 &mut app,
2625 AgentEvent::AgentEnd {
2626 messages: vec![dup_msg],
2627 },
2628 );
2629
2630 assert_eq!(
2632 app.conversation.len(),
2633 1,
2634 "AgentEnd must not duplicate messages already in conversation (pi-style)"
2635 );
2636 }
2637
2638 #[test]
2641 fn test_handle_clear_when_streaming_interrupts() {
2642 let tmp = tempdir().unwrap();
2643 let cwd = tmp.path().to_path_buf();
2644 let session = SessionManager::in_memory(&cwd);
2645 let config = make_config(cwd.clone());
2646 let mut app = App::new(config, session);
2647 app.is_streaming = true;
2648 app.follow_up_queue
2649 .lock()
2650 .unwrap()
2651 .enqueue(AgentMessage::user("q"));
2652
2653 handle_clear(&mut app);
2654
2655 assert!(!app.is_streaming, "Streaming should be interrupted");
2656 assert!(
2657 app.follow_up_queue.lock().unwrap().is_empty(),
2658 "Queued messages should be restored"
2659 );
2660 }
2661
2662 #[test]
2663 fn test_handle_clear_not_streaming_clears_editor() {
2664 let tmp = tempdir().unwrap();
2665 let cwd = tmp.path().to_path_buf();
2666 let session = SessionManager::in_memory(&cwd);
2667 let config = make_config(cwd.clone());
2668 let mut app = App::new(config, session);
2669 app.is_streaming = false;
2670 app.editor.borrow_mut().editor.set_text("some text");
2671 app.last_clear_time = std::time::Instant::now() - std::time::Duration::from_secs(10);
2673
2674 handle_clear(&mut app);
2675
2676 assert!(
2677 app.editor.borrow().editor.get_text().is_empty(),
2678 "Editor should be cleared"
2679 );
2680 }
2681
2682 #[test]
2683 fn test_handle_clear_double_press_exits() {
2684 let tmp = tempdir().unwrap();
2685 let cwd = tmp.path().to_path_buf();
2686 let session = SessionManager::in_memory(&cwd);
2687 let config = make_config(cwd.clone());
2688 let mut app = App::new(config, session);
2689 app.is_streaming = false;
2690 app.last_clear_time = std::time::Instant::now();
2692
2693 handle_clear(&mut app);
2694
2695 assert!(app.should_quit, "Double Ctrl+C should exit");
2696 }
2697
2698 #[test]
2699 fn test_handle_thinking_cycle() {
2700 let tmp = tempdir().unwrap();
2701 let cwd = tmp.path().to_path_buf();
2702 let session = SessionManager::in_memory(&cwd);
2703 let config = AppConfig {
2704 available_models: vec!["model".into()],
2705 model: "model".into(),
2706 model_supports_reasoning: true,
2707 tool_execution: ToolExecutionMode::Parallel,
2708 ..make_config(cwd.clone())
2709 };
2710 let mut app = App::new(config, session);
2711
2712 app.thinking_level = Some("off".into());
2714
2715 handle_thinking_cycle(&mut app);
2716 assert_eq!(app.thinking_level.as_deref(), Some("xhigh"));
2717
2718 handle_thinking_cycle(&mut app);
2719 assert_eq!(app.thinking_level.as_deref(), Some("high"));
2720
2721 handle_thinking_cycle(&mut app);
2722 assert_eq!(app.thinking_level.as_deref(), Some("medium"));
2723
2724 handle_thinking_cycle(&mut app);
2725 assert_eq!(app.thinking_level.as_deref(), Some("low"));
2726
2727 handle_thinking_cycle(&mut app);
2728 assert_eq!(app.thinking_level.as_deref(), Some("off"));
2729 }
2730
2731 #[test]
2732 fn test_handle_model_cycle_forward() {
2733 let tmp = tempdir().unwrap();
2734 let cwd = tmp.path().to_path_buf();
2735 let session = SessionManager::in_memory(&cwd);
2736 let config = AppConfig {
2737 available_models: vec!["A".into(), "B".into(), "C".into()],
2738 model: "A".into(),
2739 model_supports_reasoning: true,
2740 tool_execution: ToolExecutionMode::Parallel,
2741 ..make_config(cwd.clone())
2742 };
2743 let mut app = App::new(config, session);
2744
2745 handle_model_cycle(&mut app, 1);
2746 assert_eq!(app.model, "B");
2747
2748 handle_model_cycle(&mut app, 1);
2749 assert_eq!(app.model, "C");
2750
2751 handle_model_cycle(&mut app, 1);
2752 assert_eq!(app.model, "A"); }
2754
2755 #[test]
2756 fn test_handle_model_cycle_backward() {
2757 let tmp = tempdir().unwrap();
2758 let cwd = tmp.path().to_path_buf();
2759 let session = SessionManager::in_memory(&cwd);
2760 let config = AppConfig {
2761 available_models: vec!["A".into(), "B".into(), "C".into()],
2762 model: "A".into(),
2763 model_supports_reasoning: true,
2764 tool_execution: ToolExecutionMode::Parallel,
2765 ..make_config(cwd.clone())
2766 };
2767 let mut app = App::new(config, session);
2768
2769 handle_model_cycle(&mut app, -1);
2770 assert_eq!(app.model, "C"); handle_model_cycle(&mut app, -1);
2773 assert_eq!(app.model, "B");
2774
2775 handle_model_cycle(&mut app, -1);
2776 assert_eq!(app.model, "A");
2777 }
2778
2779 #[test]
2780 fn test_handle_tools_expand_toggles() {
2781 let tmp = tempdir().unwrap();
2782 let cwd = tmp.path().to_path_buf();
2783 let session = SessionManager::in_memory(&cwd);
2784 let config = make_config(cwd.clone());
2785 let mut app = App::new(config, session);
2786
2787 app.tools_expanded = false;
2788 app.collapse_tool_output = true;
2789
2790 handle_tools_expand(&mut app);
2791
2792 assert!(app.tools_expanded, "tools_expanded should be true");
2793 assert!(!app.collapse_tool_output, "collapse should be false");
2794
2795 handle_tools_expand(&mut app);
2796
2797 assert!(!app.tools_expanded, "tools_expanded should be false");
2798 assert!(app.collapse_tool_output, "collapse should be true");
2799 }
2800
2801 #[test]
2802 fn test_handle_follow_up_queues_when_streaming() {
2803 let tmp = tempdir().unwrap();
2804 let cwd = tmp.path().to_path_buf();
2805 let session = SessionManager::in_memory(&cwd);
2806 let config = make_config(cwd.clone());
2807 let mut app = App::new(config, session);
2808 app.is_streaming = true;
2809
2810 handle_follow_up(&mut app, "follow-up text".into());
2811
2812 assert_eq!(app.follow_up_queue.lock().unwrap().len(), 1);
2813 assert_eq!(
2814 app.follow_up_queue.lock().unwrap().drain()[0]
2815 .content
2816 .clone(),
2817 "follow-up text"
2818 );
2819 }
2820
2821 #[test]
2822 fn test_handle_dequeue_restores_messages() {
2823 let tmp = tempdir().unwrap();
2824 let cwd = tmp.path().to_path_buf();
2825 let session = SessionManager::in_memory(&cwd);
2826 let config = make_config(cwd.clone());
2827 let mut app = App::new(config, session);
2828 app.follow_up_queue
2829 .lock()
2830 .unwrap()
2831 .enqueue(AgentMessage::user("msg1"));
2832 app.follow_up_queue
2833 .lock()
2834 .unwrap()
2835 .enqueue(AgentMessage::user("msg2"));
2836
2837 handle_dequeue(&mut app);
2838
2839 assert!(
2840 app.follow_up_queue.lock().unwrap().is_empty(),
2841 "Queues should be empty"
2842 );
2843 assert!(
2844 app.editor.borrow().editor.get_text().contains("msg1"),
2845 "Editor should contain msg1"
2846 );
2847 assert!(
2848 app.editor.borrow().editor.get_text().contains("msg2"),
2849 "Editor should contain msg2"
2850 );
2851 }
2852
2853 #[test]
2854 fn test_handle_compact_toggle_toggles_flag() {
2855 let tmp = tempdir().unwrap();
2856 let cwd = tmp.path().to_path_buf();
2857 let session = SessionManager::in_memory(&cwd);
2858 let config = make_config(cwd.clone());
2859 let mut app = App::new(config, session);
2860
2861 app.auto_compact = true;
2862 handle_compact_toggle(&mut app);
2863 assert!(!app.auto_compact, "Should toggle off");
2864
2865 handle_compact_toggle(&mut app);
2866 assert!(app.auto_compact, "Should toggle back on");
2867 }
2868
2869 #[test]
2870 fn test_submit_resets_scroll_offset() {
2871 let tmp = tempdir().unwrap();
2872 let cwd = tmp.path().to_path_buf();
2873 let session = SessionManager::in_memory(&cwd);
2874 let config = make_config(cwd.clone());
2875 let mut app = App::new(config, session);
2876
2877 app.scroll_offset = 20;
2878 app.is_streaming = true;
2879 submit_message(&mut app, "test".into());
2880
2881 assert_eq!(app.scroll_offset, 0, "submit should reset scroll_offset");
2882 }
2883
2884 #[test]
2885 fn test_scroll_indicator_shown_when_scrolled() {
2886 use crate::agent::ui::theme;
2887 theme::init_theme(Some("dark"), false);
2888
2889 let tmp = tempdir().unwrap();
2890 let cwd = tmp.path().to_path_buf();
2891 let session = SessionManager::in_memory(&cwd);
2892 let config = make_config(cwd.clone());
2893 let mut app = App::new(config, session);
2894
2895 app.messages.push(DisplayMsg::Info("msg 1".into()));
2898 app.messages.push(DisplayMsg::Info("msg 2".into()));
2899 app.messages.push(DisplayMsg::Info("msg 3".into()));
2900 app.messages.push(DisplayMsg::Info("msg 4".into()));
2901
2902 app.scroll_offset = 0;
2903 let lines_scrolled_0 = compose_ui_test(&mut app, 80);
2904 let text_0 = lines_scrolled_0.join("\n");
2905 assert!(text_0.contains("msg 1"), "Should show msg 1 at offset 0");
2907 assert!(text_0.contains("msg 4"), "Should show msg 4 at offset 0");
2908 assert!(!text_0.contains("↑"), "No scroll indicator at offset 0");
2910
2911 app.scroll_offset = 2;
2912 let lines_scrolled = compose_ui_test(&mut app, 80);
2913 let text = lines_scrolled.join("\n");
2914 assert!(
2919 !text.contains("msg 2"),
2920 "msg 2 should be hidden when scrolled"
2921 );
2922 assert!(text.contains("msg 3"), "msg 3 should still show");
2923 assert!(text.contains("msg 4"), "msg 4 should still show");
2924 assert!(text.contains("↑"), "Should show scroll indicator");
2926 }
2927
2928 fn make_config(cwd: std::path::PathBuf) -> AppConfig {
2930 AppConfig {
2931 model: "test-model".into(),
2932 system_prompt: String::new(),
2933 tools: vec![],
2934 agent_tools: vec![],
2935 extensions: vec![],
2936 provider: Box::new(MockProvider),
2937 cwd,
2938 thinking_level: None,
2939 git_branch: None,
2940 available_models: vec![],
2941 hide_thinking: true,
2942 collapse_tool_output: true,
2943 interactive: true,
2944 settings: crate::agent::settings::Settings::default(),
2945 context_files: vec![],
2946 skills: vec![],
2947 model_supports_reasoning: false,
2948 tool_execution: ToolExecutionMode::Parallel,
2949 }
2950 }
2951}