Skip to main content

rab/agent/ui/
app.rs

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
30/// Thinking level cycle order (matching pi's thinking level enum).
31/// Thinking level cycle order. Cycles from highest to lowest so the first
32/// press from the default (xhigh) goes to "high" (a step down), not to "off".
33const THINKING_LEVELS: &[&str] = &["xhigh", "high", "medium", "low", "off"];
34
35/// Configuration for the UI app.
36pub 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    /// Context files (AGENTS.md / CLAUDE.md) loaded for the session.
52    pub context_files: Vec<String>,
53    /// Tool execution mode (parallel by default).
54    pub tool_execution: ToolExecutionMode,
55    /// Skills loaded for the session (used for /skill:name expansion).
56    pub skills: Vec<crate::agent::Skill>,
57    /// Whether the current model supports reasoning (for showing thinking level in footer).
58    pub model_supports_reasoning: bool,
59}
60
61/// Main application state.
62pub 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    /// Slash commands from all extensions.
72    #[allow(dead_code)]
73    commands: Vec<(String, String)>,
74
75    /// Available models for the model selector.
76    available_models: Vec<String>,
77
78    /// Conversation history (AgentMessage).
79    conversation: Vec<AgentMessage>,
80
81    /// Rendered display messages (legacy - being migrated to Components).
82    messages: Vec<DisplayMsg>,
83
84    /// Component-based chat area - mirrors pi's `this.chatContainer`.
85    /// Components are added here in handle_agent_event instead of pushing to messages.
86    pub chat_container: RefContainer,
87
88    // ── Section components for the UI layout (written by compose_ui) ──
89    /// Pending streaming text section.
90    pub pending_section: std::rc::Rc<crate::tui::components::DynamicLines>,
91    /// Status text section (transient, dim).
92    pub status_section: std::rc::Rc<crate::tui::components::DynamicLines>,
93    /// Queued messages section.
94    pub queued_section: std::rc::Rc<crate::tui::components::DynamicLines>,
95    /// Working indicator section.
96    pub working_section: std::rc::Rc<crate::tui::components::DynamicLines>,
97
98    /// The chat editor (shared ownership - App mutates, TUI.root renders).
99    editor: Rc<RefCell<ChatEditor>>,
100
101    /// Agent event channel.
102    event_tx: mpsc::UnboundedSender<AgentEvent>,
103    event_rx: mpsc::UnboundedReceiver<AgentEvent>,
104
105    /// Streaming state.
106    is_streaming: bool,
107    pending_text: Option<String>,
108    pending_thinking: Option<String>,
109
110    /// Display settings.
111    hide_thinking: bool,
112    collapse_tool_output: bool,
113    /// Global toggle: expand all tool outputs (Ctrl+O). Inverted of collapse_tool_output.
114    tools_expanded: bool,
115
116    /// Chat scroll offset (lines scrolled up from bottom).
117    scroll_offset: usize,
118
119    /// Timestamp of last Ctrl+C for double-press detection (pi-style).
120    last_clear_time: std::time::Instant,
121
122    /// Exit flag.
123    should_quit: bool,
124
125    /// Token usage from last response.
126    last_usage: Option<Usage>,
127
128    /// Agent abort handle for Ctrl+C.
129    agent_abort: Option<tokio::task::AbortHandle>,
130
131    /// Session persistence.
132    session: Option<SessionManager>,
133
134    /// Footer (shared ownership - App mutates, TUI.root renders).
135    footer: Rc<RefCell<Footer>>,
136
137    /// Pending tool executions keyed by tool call ID.
138    /// Used to update ToolExecComponent when ToolResult arrives (pi's `pendingTools` Map).
139    pending_tools: HashMap<String, Weak<RefCell<crate::agent::ui::components::ToolExecComponent>>>,
140
141    /// Start times for pending tool calls, keyed by tool call ID.
142    /// Used to compute duration for bash and other tools.
143    tool_call_start_times: HashMap<String, std::time::Instant>,
144
145    /// Streaming assistant message component (pi's `streamingComponent`).
146    /// Created on first TextDelta, updated in-place, cleared on TurnEnd/AgentEnd.
147    streaming_component:
148        Option<Weak<RefCell<crate::agent::ui::components::AssistantMessageComponent>>>,
149
150    /// Active bash execution component (for bang commands - updated when result arrives).
151    bash_component: Option<Weak<RefCell<crate::agent::ui::components::BashExecution>>>,
152
153    /// Working indicator.
154    working: WorkingIndicator,
155
156    /// Transient status text (pi-style: replaces previous status, not added to chat).
157    status_text: Option<String>,
158
159    /// Agent tools (for tool execution).
160    agent_tools: Arc<Vec<Box<dyn AgentTool>>>,
161    /// Extensions.
162    extensions: Arc<Vec<Box<dyn Extension>>>,
163
164    /// Steering queue: messages delivered after current turn's tool calls finish,
165    /// before the next LLM call. Shared with the agent loop.
166    steering_queue: Arc<std::sync::Mutex<PendingMessageQueue>>,
167    /// Follow-up queue: messages delivered only after the agent has no more
168    /// tool calls (fully idle). Shared with the agent loop.
169    follow_up_queue: Arc<std::sync::Mutex<PendingMessageQueue>>,
170    /// Tool execution mode (parallel by default).
171    tool_execution: ToolExecutionMode,
172
173    /// Skills loaded for the session (/skill:name expansion).
174    skills: Vec<crate::agent::Skill>,
175
176    /// Auto-compact toggle state.
177    auto_compact: bool,
178
179    /// Settings reference for persisting toggle changes.
180    settings: crate::agent::settings::Settings,
181
182    /// Header component (welcome/onboarding). Stored as Rc<RefCell> so
183    /// handle_tools_expand can toggle its expanded state (matching pi's
184    /// behavior where setToolsExpanded expands both the header and all
185    /// expandable chat children).
186    header: Rc<RefCell<crate::agent::ui::components::HeaderComponent>>,
187    // ── Message rendering cache (avoids re-rendering messages every frame) ──
188    // Cache fields removed - messages now rendered via Components in chat_container.
189}
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        // Collect slash commands
200        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        // Load session messages
219        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        // Startup info: context files, skills, tools (pi-style loaded resources listing)
224        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        // Combine startup info with history (legacy - for session saving / tests)
243        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        // Populate chat_container with initial messages (startup info + history).
253        // Add spacers between messages matching pi's addMessageToChat behavior.
254        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    /// Refresh git branch for footer display.
329    /// Called on AgentStart to match pi's FooterDataProvider.onBranchChange.
330    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
350/// Run the interactive UI.
351pub async fn run(config: AppConfig, session: SessionManager) -> anyhow::Result<()> {
352    // Initialize theme system
353    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    // Main-screen mode (like pi) - no alternate screen, no clear.
359    // Content writes from current cursor position (after shell prompt).
360    // Terminal scrolls naturally, editor/footer appear at the bottom.
361    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    // Disable clear_on_shrink to avoid full redraws during streaming
367    // (content grows/shrinks frequently as pending text is flushed).
368    tui.set_clear_on_shrink(false);
369    let mut app = App::new(config, session);
370
371    // Set up the component tree in TUI.root (matching pi's TUI.extend(Container))
372    // Order: header → chat_container (messages) → pending → status → queued → working → editor → footer
373    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    // Initialize editor border color
398    app.editor.borrow_mut().update_border_color(
399        app.thinking_level.as_deref(),
400        &app.theme as &dyn crate::tui::Theme,
401    );
402
403    // Cache terminal dimensions to avoid expensive syscall on every frame.
404    // Only re-query when a resize event is detected or periodically.
405    let mut cols: u16 = 80;
406    let mut rows: u16 = 24;
407    let mut dirty = true; // force initial render
408
409    loop {
410        // Poll for events (pi-style: process input before rendering)
411        // Reduced poll frequency: 16ms active (~60fps), 50ms idle - terminal UI
412        // doesn't benefit from >60fps and lower frequency saves CPU/battery.
413        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                    // TUI overlay routing first (overlays get first crack at input)
423                    if !tui.route_input(&key) {
424                        handle_input(&mut app, &mut tui, &key);
425                    }
426                }
427                terminal::TerminalEvent::Paste(content) => {
428                    // Route to focused overlay first (e.g. Input in settings),
429                    // fall back to the main Editor.
430                    if !tui.route_paste(&content) {
431                        app.editor.borrow_mut().editor.handle_paste(&content);
432                    }
433                }
434                terminal::TerminalEvent::Resize(w, h) => {
435                    // Update editor's terminal height for dynamic max-visible-lines
436                    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        // Drain agent events
444        while let Ok(event) = app.event_rx.try_recv() {
445            handle_agent_event(&mut app, event);
446            dirty = true;
447        }
448
449        // Check terminal size only when we're about to render
450        // (avoids expensive ioctl syscall on idle frames)
451        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        // Tick the working indicator - sets dirty when spinner advances
458        if app.working.tick() {
459            dirty = true;
460        }
461
462        // Tick active tool timers (bash elapsed display, matching pi's setInterval(1000))
463        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        // Compose and render only when state has changed
478        if dirty {
479            // Update section components from compose_ui
480            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        // Pi: clear transient status after rendering
487        app.status_text = None;
488
489        if app.should_quit {
490            break;
491        }
492    }
493
494    // Cleanup - move cursor past all rendered content so the shell prompt
495    // appears on a fresh line after the footer (matching pi's stop() behavior).
496    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
504/// Update UI section components from app state.
505/// Each section is a child of TUI.root rendered in the correct order.
506///
507/// Layout (top to bottom):
508///   header → chat_container (messages) → pending → status → queued → working → editor → footer
509fn compose_ui(app: &mut App, width: usize) {
510    // ── Pending (streaming) text ──
511    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    // ── Transient status text (pi-style: replaces previous status, not added to chat) ──
555    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    // ── Queued messages (pi-style: shown between chat and editor) ──
563    // Shows both steering and follow-up queue contents.
564    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    // ── Working indicator (pi-style: blank line + spinner before editor) ──
604    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
610/// Handle keyboard input. Mirrors pi's InteractiveMode key dispatch:
611///
612/// 1. Overlays handled via TUI.route_input - checked first in event loop
613/// 2. ChatEditor::handle_input checks app-level keys and returns InputAction
614/// 3. App.rs matches on InputAction to perform side effects
615///
616/// This keeps text-editing logic in the Editor component (via ChatEditor)
617/// and app-level side effects (aborting agents, toggling settings, etc.) here.
618fn handle_input(app: &mut App, tui: &mut TUI, key: &KeyEvent) {
619    // ── Check if any TUI overlay is active (help, model selector, etc.) ──
620    if tui.has_overlays() {
621        tui.pop_overlay();
622        return;
623    }
624
625    // ── Route input to root container children (header, etc.) ──
626    // Root children (header → chat_container → pending → etc.) get a chance
627    // to handle input before the editor. Components that don't consume the
628    // event return false so it flows through to the editor.
629    if tui.root.handle_input(key) {
630        return;
631    }
632
633    // ── Dispatch to ChatEditor (mirrors pi's CustomEditor.handleInput) ──
634    // Borrow the editor in a let binding so the RefMut drops before we mutate App.
635    let action = app.editor.borrow_mut().handle_input(key);
636    match action {
637        InputAction::Handled => {}
638        InputAction::Escape => {
639            // Pi-style: abort streaming or bash, else clear editor
640            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            // Propagate to ALL existing components in chat container (matching pi)
667            {
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            // Update streaming component if it exists
674            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            // Persist only the affected field (incremental save)
678            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
713// =============================================================================
714// New action handlers (pi-compatible)
715// =============================================================================
716
717/// Handle Ctrl+C: clear editor (double-press within 500ms = exit).
718fn 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        // Double Ctrl+C within 500ms = exit (pi-style)
727        app.should_quit = true;
728    } else {
729        app.editor.borrow_mut().editor.set_text("");
730        app.status_text = Some("Cleared".into());
731    }
732}
733
734/// Cycle thinking level: off → low → medium → high → xhigh → off
735fn 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    // Update provider's reasoning effort so API calls use the new level
757    app.provider.set_reasoning_effort(Some(next));
758    app.status_text = Some(format!("Thinking level: {}", next));
759}
760
761/// Cycle model forward (dir=1) or backward (dir=-1).
762fn 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    // All rab models support reasoning (deepseek-v4-flash, deepseek-v4-pro).
779    app.footer.borrow_mut().set_model_supports_reasoning(true);
780    app.status_text = Some(format!("Model: {}", app.model));
781}
782
783/// Toggle all tool output expansion (Ctrl+O).
784/// Mirrors pi's `toggleToolOutputExpansion()` which iterates all chat_container
785/// children and calls `setExpanded()` on `Expandable` components.
786fn handle_tools_expand(app: &mut App) {
787    app.tools_expanded = !app.tools_expanded;
788    app.collapse_tool_output = !app.tools_expanded;
789
790    // Expand/collapse header (welcome/onboarding) — matching pi's setToolsExpanded
791    // which expands both the active header and all expandable chat children.
792    app.header.borrow_mut().set_expanded(app.tools_expanded);
793
794    // Propagate to all children in chat_container
795    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
813/// Open external editor ($VISUAL / $EDITOR) for current editor content.
814fn 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, &current_text) {
835        app.status_text = Some(format!("Failed to write temp file: {}", e));
836        return;
837    }
838
839    // Fork and exec the editor
840    let parts: Vec<&str> = editor_cmd.split(' ').collect();
841    let (editor, args) = parts.split_first().unwrap_or((&"", &[]));
842
843    // Stop TUI, run editor, resume
844    // For simplicity, we use std::process::Command which blocks
845    app.status_text = Some(format!("Opening {} ...", editor_cmd));
846
847    // Use std::process since we need to block the async runtime
848    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
874/// Queue a follow-up message (Alt+Enter). Pushes to follow-up queue during streaming
875/// (delivered only after agent has no more tool calls). When idle, submits immediately.
876fn 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        // Not streaming - submit immediately
885        submit_message(app, text);
886    }
887}
888
889/// Restore queued messages to editor (Alt+Up).
890/// Restores from the follow-up queue (steering messages are consumed during streaming).
891fn 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
911/// Toggle auto-compact indicator (Ctrl+Shift+C).
912fn 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
922/// Interrupt streaming agent and restore queued messages to editor.
923fn 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    // Restore follow-up queue messages to editor (steering are mid-stream, not restorable).
932    // Use try_lock to avoid deadlock if the agent loop holds the mutex when abort is called.
933    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
950/// Open the model selector overlay.
951fn 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, &current, &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
964/// Submit or queue a user message. When streaming, pushes to the steering queue
965/// (delivered after current turn's tool calls finish, before next LLM call).
966/// When idle, starts a new agent loop immediately.
967fn submit_message(app: &mut App, message: String) {
968    app.scroll_offset = 0;
969    let trimmed = message.trim().to_string();
970
971    // Don't submit empty messages (pi-style)
972    if trimmed.is_empty() {
973        return;
974    }
975
976    // Handle /skill:name [args] expansion (pi-style: before command dispatch)
977    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    // Handle /commands (need TUI from app for overlays)
998    if trimmed.starts_with('/') {
999        // If TUI was stored on App, we'd use it here. For now, just handle basic commands.
1000        handle_slash_command(app, &trimmed);
1001        return;
1002    }
1003
1004    // Handle ! and !! bang commands
1005    if let Some((cmd, _exclude)) = parse_bang_command(&trimmed) {
1006        handle_bang_command(app, cmd);
1007        return;
1008    }
1009
1010    // Normal message submission to LLM
1011    // Add Component to chat_container with spacer
1012    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        // Steering: delivered after current turn's tool calls finish, before next LLM call
1022        app.steering_queue
1023            .lock()
1024            .unwrap()
1025            .enqueue(AgentMessage::user(trimmed));
1026        return;
1027    }
1028
1029    start_agent_loop(app, trimmed);
1030}
1031
1032/// Actually start an agent loop (not queued).
1033fn 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    // Ensure AgentEnd is always emitted, even on panic inside the spawned task.
1053    // Without this, is_streaming would remain true forever, blocking new submissions.
1054    let handle = tokio::spawn(async move {
1055        // Drop guard sends AgentEnd on scope exit (normal, error, or panic)
1056        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                // AgentEnd already sent by run_agent_loop on success
1094                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
1112/// Handle slash commands.
1113fn handle_slash_command(app: &mut App, input: &str) {
1114    // Detect terminal size for overlay-like rendering
1115    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    // /model opens model selector
1122    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, &current, &app.theme);
1126        let lines = selector.render(cols as usize);
1127        // Render the model selector inline (not as overlay from here)
1128        // This is a stopgap until slash commands get TUI access
1129        for line in lines {
1130            app.messages.push(DisplayMsg::Info(line));
1131        }
1132        return;
1133    }
1134
1135    // /help
1136    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    // /quit
1144    if cmd_name == "quit" || cmd_name == "q" {
1145        app.should_quit = true;
1146        return;
1147    }
1148
1149    // Unknown command
1150    app.status_text = Some(format!(
1151        "Unknown command: /{}. Type /help for available commands.",
1152        cmd_name
1153    ));
1154}
1155
1156/// Handle ! and !! bang commands.
1157/// Renders via BashExecutionComponent (borders, spinner, expand/collapse)
1158/// matching pi's handleBashCommand.
1159fn handle_bang_command(app: &mut App, command: String) {
1160    let cwd = app.cwd.clone();
1161    let tx = app.event_tx.clone();
1162
1163    // Add BashExecutionComponent to chat_container (track for result updates)
1164    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        // Stream stdout and stderr concurrently using tokio async reads
1226        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        // Wait for process to finish
1273        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
1303/// Add a Component to chat_container with a spacer before it if chat_container is not empty.
1304/// Mirrors pi's `addMessageToChat()` which adds `new Spacer(1)` before each message
1305/// when `this.chatContainer.children.length > 0`.
1306pub 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/// Format a tool call header matching pi's per-tool renderCall patterns.
1315#[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
1453/// Handle agent events from the channel.
1454fn 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            // Refresh git branch on each agent start (matches pi's FooterDataProvider)
1462            app.refresh_git_branch();
1463        }
1464        AgentEvent::TurnStart => {}
1465        AgentEvent::TextDelta { delta } => {
1466            // Progressive streaming: create or update AssistantMessageComponent in-place
1467            if let Some(weak) = app.streaming_component.as_ref().and_then(|w| w.upgrade()) {
1468                weak.borrow_mut().append_text(&delta);
1469            } else {
1470                // First text delta - create streaming component
1471                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            // Progressive thinking: add thinking block to streaming component
1484            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                // First thinking delta without text - create component with just thinking
1489                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            // Clear streaming component so answer text from the next turn creates a new
1503            // assistant message component (below the tool execution, matching pi).
1504            app.streaming_component = None;
1505            // Look up tool renderer from agent tools
1506            let renderer = app
1507                .agent_tools
1508                .iter()
1509                .find(|t| t.name() == name)
1510                .and_then(|t| t.renderer());
1511
1512            // Create a combined ToolExecComponent with renderer, handling per-tool setup
1513            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            // Legacy path
1556            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            // Progressive args update - re-render call header with new args
1564            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                // Update existing ToolExecComponent
1579                if let Some(comp) = weak.upgrade() {
1580                    if name == "bash" {
1581                        // Bash tool result: set duration, exit code, truncation info
1582                        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                // Bash result (from bang command or LLM tool call without preceding ToolCall):
1608                // Update existing BashExecutionComponent or create new one
1609                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                    // No tracked component - create new BashExecutionComponent
1622                    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                // Non-bash tool result without preceding call (shouldn't happen):
1640                // Fall back to generic ToolResultComponent
1641                chat_add(
1642                    app,
1643                    std::boxed::Box::new(crate::agent::ui::components::ToolResultComponent::new(
1644                        &content, is_error,
1645                    )),
1646                );
1647            }
1648            // Legacy path
1649            app.messages.push(DisplayMsg::ToolResult {
1650                content,
1651                compact: None,
1652                is_error,
1653            });
1654        }
1655        AgentEvent::ToolProgress { content, is_error } => {
1656            // Stream partial bash output to the tracked bash component
1657            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            // A queued message was injected by the agent loop (from steering or follow-up queue)
1667            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            // Show abort/error text inline in the streaming component (matches pi)
1677            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            // Streaming component is complete - clear reference (text persists in chat)
1685            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            // Persist new messages to session and update conversation state
1697            // (user message is in prompts, not duplicated in app.conversation)
1698            if let Some(ref mut session) = app.session {
1699                for msg in messages {
1700                    session.append_message(msg);
1701                }
1702            }
1703            // Extend app.conversation so subsequent turns have full context
1704            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            // Note: follow-up messages are handled internally by the agent loop
1717            // (outer loop in run_agent_loop drains the follow-up queue).
1718            // Queued messages from when the agent was idle will be submitted
1719            // via the normal submit_message path on next user input.
1720        }
1721    }
1722}
1723
1724fn flush_text(app: &mut App) {
1725    if let Some(text) = app.pending_text.take()
1726        && !text.is_empty()
1727    {
1728        // Add Component to chat_container with spacer
1729        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        // Legacy path
1735        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        // Add Component to chat_container with spacer
1744        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        // Legacy path
1751        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
1763/// Collect tool definitions from the app's agent tools.
1764fn 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
1778/// Parse a ! or !! bang command from input.
1779fn 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
1799/// Extract the exit code from a bash error result content.
1800/// Looks for patterns like "Command exited with code N".
1801fn 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
1816/// Extract the full output path from a bash result content.
1817/// Looks for patterns like "Full output: /path/to/file".
1818fn 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        // First compose
1887        let before = compose_ui_test(&mut app, width);
1888        // Type "/"
1889        let slash = KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE);
1890        app.editor.borrow_mut().editor.handle_input(&slash);
1891        // Second compose
1892        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        // Find the border lines and verify they exist in both
1903        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        // The changed line should be the same index in both
1914        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        // Header (matches compose_ui)
1926        // Model info, thinking level, token stats are shown in the footer.
1927        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        // Apply scroll offset (matching compose_ui)
1939        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        // Pending (streaming) text - matches compose_ui
1951        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        // Queued messages - matches compose_ui
1984        let follow_msgs: Vec<String> = {
1985            let q = app.follow_up_queue.lock().unwrap();
1986            // Drain a copy: lock briefly, clone messages
1987            // For the compose_ui test helper, we just read the count
1988            if q.is_empty() {
1989                vec![]
1990            } else {
1991                // Show placeholder based on queue count
1992                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    // ── New tests ──
2014
2015    #[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        // Simulate streaming in progress
2045        app.is_streaming = true;
2046        app.follow_up_queue.lock().unwrap().clear();
2047
2048        // Submit a message while streaming (Enter during streaming = steering)
2049        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 a message while NOT streaming
2092        submit_message(&mut app, "hello".into());
2093
2094        // Should have one user message (no startup info messages)
2095        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        // AgentEnd no longer processes follow-up queue — the agent loop handles it.
2296        // The queue should remain intact.
2297        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        // Simulate Ctrl+C
2345        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            // The line should be padded to exactly width (no undershoot)
2386            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        // No queued messages - compose
2429        let before = compose_ui_test(&mut app, 80);
2430
2431        // Add queued messages
2432        app.follow_up_queue
2433            .lock()
2434            .unwrap()
2435            .enqueue(AgentMessage::user("msg1"));
2436        let after = compose_ui_test(&mut app, 80);
2437
2438        // Should have more lines with queued messages (count label and hint)
2439        assert!(
2440            after.len() > before.len(),
2441            "Line count should increase when queued messages are present"
2442        );
2443
2444        // Queued messages appear between messages and editor.
2445        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    // ── Conversation / send behavior (matches pi) ────────────────
2453
2454    #[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        // Mark as streaming so submit_message queues instead of spawning a tokio task
2484        app.is_streaming = true;
2485        let initial_len = app.conversation.len();
2486
2487        // Submit a message while streaming (queues, no tokio::spawn)
2488        submit_message(&mut app, "test message".into());
2489
2490        // Pi: submit_message sends to agent loop, which emits message_end for persistence.
2491        // The message is NOT added to app.conversation directly - it flows through AgentEnd.
2492        assert_eq!(
2493            app.conversation.len(),
2494            initial_len,
2495            "submit_message must not add to app.conversation (avoids double-send)"
2496        );
2497
2498        // But the display message IS added immediately (so the UI shows it)
2499        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        // Simulate the messages that AgentEnd receives from run_agent_loop
2537        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        // Fire AgentEnd
2553        handle_agent_event(
2554            &mut app,
2555            AgentEvent::AgentEnd {
2556                messages: agent_messages,
2557            },
2558        );
2559
2560        // Pi: all messages from the turn are added to conversation on AgentEnd.
2561        // The user message appears once (not duplicated), followed by assistant responses.
2562        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        // Simulate a message already in conversation (e.g. from a previous turn)
2606        let existing = AgentMessage::user("existing");
2607        let existing_id = existing.id.clone();
2608        app.conversation.push(existing);
2609
2610        // AgentEnd fires with the SAME message id - should NOT duplicate
2611        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        // Should still have exactly 1 message (deduplicated by id)
2631        assert_eq!(
2632            app.conversation.len(),
2633            1,
2634            "AgentEnd must not duplicate messages already in conversation (pi-style)"
2635        );
2636    }
2637
2638    // ── New actions tests ──
2639
2640    #[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        // Set last_clear_time far in the past so double-press doesn't trigger
2672        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        // Set last_clear_time to just a few ms ago to trigger double-press detection
2691        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        // Start from off
2713        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"); // wraps around
2753    }
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"); // wraps around backwards
2771
2772        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        // Add enough messages that we can test scrolling
2896        // Use Info messages which render simply as a single line
2897        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        // Should have all info messages visible
2906        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        // Should NOT have scroll indicator
2909        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        // msg 1 and 2 exceed the scroll offset (there are 4 messages)
2915        // We scroll past 2 lines, so msg 1 should still be visible
2916        // Actually, Info lines: " msg 1", " msg 2", " msg 3", " msg 4" = 4 lines
2917        // Scroll 2 → skip first 2 → show msg 3, msg 4
2918        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        // Should show scroll indicator
2925        assert!(text.contains("↑"), "Should show scroll indicator");
2926    }
2927
2928    /// Helper to create a minimal AppConfig for testing.
2929    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}