Skip to main content

rab/agent/ui/
app.rs

1use std::cell::RefCell;
2use std::collections::HashMap;
3use std::io::Write;
4use std::path::PathBuf;
5use std::rc::{Rc, Weak};
6use std::sync::Arc;
7use std::time::Duration;
8
9use crate::agent::extension::ToolRenderer;
10use yoagent::types::AgentTool;
11
12use crate::agent::AgentSession;
13use crate::agent::extension::{CommandResult, Extension};
14use crate::agent::footer_data_provider::FooterDataProvider;
15use crate::agent::session::SessionEntry;
16use crate::auth;
17use crate::provider;
18use crate::provider::ProviderRegistry;
19
20use crate::agent::ui::chat_editor::{ChatEditor, InputAction};
21
22use crate::agent::ui::components::EditorComponent;
23use crate::agent::ui::components::FooterComponent;
24use crate::agent::ui::components::InfoMessageComponent;
25use crate::agent::ui::footer::Footer;
26use crate::agent::ui::theme::RabTheme;
27use crate::agent::ui::working::WorkingIndicator;
28use crate::builtin::commands::SessionInfoInternal;
29use crate::tui::Component;
30use crate::tui::TUI;
31use crate::tui::focusable::Focusable;
32
33/// Result from an overlay lifecycle — checked by the main loop after route_input.
34#[derive(Debug, Clone)]
35pub enum OverlayResult {
36    /// User selected a model (provider/id string).
37    ModelSelected(String),
38    /// User accepted scoped model changes — persist to settings.
39    ScopedModelsAccepted(Option<Vec<String>>),
40    /// User cancelled — close overlay, no persist.
41    ScopedModelsCancelled,
42    /// User selected a provider for login.
43    LoginProviderSelected(String),
44    /// User provided an API key for login.
45    LoginApiKeyProvided { provider: String, key: String },
46    /// User selected an auth type for login.
47    LoginAuthTypeSelected(AuthType),
48    /// User selected a provider for logout.
49    LogoutProviderSelected(String),
50}
51
52use crate::agent::ui::components::oauth_selector::AuthType;
53use crate::agent::ui::theme::ThemeKey;
54use crate::tui::components::Spacer;
55use crate::tui::components::Text;
56use crate::tui::terminal::{self, ProcessTerminal, TerminalTrait};
57use crossterm::event::KeyEvent;
58use tokio::sync::mpsc;
59
60/// Thinking level cycle order (matching pi's thinking level enum). Cycles from
61/// highest to lowest so the first press from the default (xhigh) goes to "high"
62/// (a step down), not to "off".
63const ALL_THINKING_LEVELS: &[&str] = &["xhigh", "high", "medium", "low", "off"];
64
65/// Get the available thinking levels for the current model, filtered by
66/// the model's `thinkingLevelMap`. Levels mapped to `null` are unsupported.
67fn available_thinking_levels(app: &App) -> Vec<&'static str> {
68    // Try to read thinkingLevelMap from the resolved model
69    let thinking_map: Option<std::collections::HashMap<String, Option<serde_json::Value>>> = app
70        .registry
71        .resolve(&app.model, Some(&app.current_provider))
72        .ok()
73        .and_then(|r| {
74            r.model_config
75                .headers
76                .get("_rab_thinking_map")
77                .and_then(|json| serde_json::from_str(json).ok())
78        });
79
80    match thinking_map {
81        Some(map) => ALL_THINKING_LEVELS
82            .iter()
83            .filter(|level| {
84                if **level == "off" {
85                    return true; // off is always available
86                }
87                // If the level is in the map and maps to null, it's unsupported
88                !matches!(map.get(**level), Some(None))
89            })
90            .copied()
91            .collect(),
92        None => ALL_THINKING_LEVELS.to_vec(),
93    }
94}
95
96/// Configuration for the UI app.
97pub struct AppConfig {
98    pub model: String,
99    pub provider: String,
100    pub system_prompt: String,
101    pub extensions: Vec<Box<dyn Extension>>,
102    pub cwd: PathBuf,
103    pub thinking_level: Option<String>,
104    pub available_models: Vec<String>,
105    pub hide_thinking: bool,
106    pub collapse_tool_output: bool,
107    pub interactive: bool,
108    pub settings: crate::agent::settings::Settings,
109    /// Context files (AGENTS.md / CLAUDE.md) loaded for the session.
110    pub context_files: Vec<String>,
111
112    /// Skills loaded for the session (used for /skill:name expansion).
113    pub skills: Vec<yoagent::skills::Skill>,
114    /// Session info Arc for /session command (shared with CommandsExtension).
115    pub session_info: Option<std::sync::Arc<std::sync::Mutex<Option<SessionInfoInternal>>>>,
116    /// API key for yoagent provider.
117    pub api_key: String,
118    /// Provider registry for model resolution and provider dispatch.
119    pub registry: Arc<ProviderRegistry>,
120}
121
122/// Main application state.
123pub struct App {
124    cwd: PathBuf,
125    model: String,
126    current_provider: String,
127    thinking_level: Option<String>,
128    system_prompt: String,
129    theme: RabTheme,
130
131    /// Slash commands from all extensions.
132    commands: Vec<(String, String)>,
133
134    /// Available models for the model selector.
135    available_models: Vec<String>,
136    /// Provider registry for model resolution and provider dispatch.
137    registry: Arc<ProviderRegistry>,
138
139    /// Component-based chat area - mirrors pi's `this.chatContainer`.
140    /// Components are added here in handle_agent_event instead of pushing to messages.
141    pub chat_container: std::rc::Rc<std::cell::RefCell<crate::tui::Container>>,
142
143    // ── Section components for the UI layout (written by compose_ui) ──
144    /// Status text section (transient, dim).
145    pub status_section: std::rc::Rc<std::cell::RefCell<crate::tui::components::DynamicLines>>,
146    /// Working indicator section.
147    pub working_section: std::rc::Rc<std::cell::RefCell<crate::tui::components::DynamicLines>>,
148
149    /// The chat editor (shared ownership - App mutates, TUI.root renders).
150    editor: Rc<RefCell<ChatEditor>>,
151
152    /// Agent event channel.
153    event_tx: mpsc::UnboundedSender<yoagent::types::AgentEvent>,
154    event_rx: mpsc::UnboundedReceiver<yoagent::types::AgentEvent>,
155
156    /// Streaming state.
157    is_streaming: bool,
158    /// Pending agent submission (set by sync handle_input, consumed by async main loop).
159    pending_submit: Option<String>,
160    /// Pending manual compaction (carries optional custom instructions).
161    pending_compact: Option<Option<String>>,
162    /// Pending auto-compaction check after AgentEnd (pi-compatible).
163    pending_auto_compact: bool,
164    /// The reused Agent (accumulates messages across turns, supports mid-turn steering).
165    agent: Option<yoagent::agent::Agent>,
166    /// Handle for the forwarding task that relays events from the agent's event
167    /// receiver to the UI channel. The Agent stays in `app.agent` during streaming.
168    forward_handle: Option<tokio::task::JoinHandle<()>>,
169
170    /// Handle for the OAuth login task, aborted on quit to avoid background polling.
171    oauth_join_handle: Option<tokio::task::JoinHandle<()>>,
172
173    /// Provider ID of an in-flight OAuth login, used to perform post-login
174    /// actions (registry refresh, model auto-selection) after the task completes.
175    pending_oauth_provider: Option<String>,
176
177    /// Display settings.
178    hide_thinking: bool,
179    collapse_tool_output: bool,
180    /// Global toggle: expand all tool outputs (Ctrl+O). Inverted of collapse_tool_output.
181    tools_expanded: bool,
182
183    /// Chat scroll offset (lines scrolled up from bottom).
184    scroll_offset: usize,
185
186    /// Timestamp of last Ctrl+C for double-press detection (pi-style).
187    last_clear_time: std::time::Instant,
188
189    /// Exit flag.
190    should_quit: bool,
191
192    /// Number of tool executions currently in-flight.
193    /// Incremented on ToolExecutionStart, decremented on ToolExecutionEnd.
194    /// Used to skip the 15s inactivity timeout while tools are running,
195    /// since long-running tools (e.g. bash) may not emit progress events.
196    pending_tool_executions: usize,
197
198    /// Bash abort handle for bang (!) commands.
199    bash_abort_handle: Option<tokio::task::AbortHandle>,
200
201    /// Session persistence via AgentSession lifecycle layer.
202    session: Option<AgentSession>,
203
204    /// Footer (shared ownership - App mutates, TUI.root renders).
205    footer: Rc<RefCell<Footer>>,
206
207    /// Footer data provider (pull-based: git branch, extension statuses).
208    footer_provider: Rc<RefCell<FooterDataProvider>>,
209
210    /// Pending tool executions keyed by tool call ID.
211    /// Used to update ToolExecComponent when ToolResult arrives (pi's `pendingTools` Map).
212    pending_tools: HashMap<String, Weak<RefCell<crate::agent::ui::components::ToolExecComponent>>>,
213
214    /// Start times for pending tool calls, keyed by tool call ID.
215    /// Used to compute duration for bash and other tools.
216    tool_call_start_times: HashMap<String, std::time::Instant>,
217
218    /// Receivers for async invalidation notifications (edit tool preview).
219    /// Polled on each render cycle to trigger re-render of tool components.
220    invalidate_rxs: Vec<tokio::sync::mpsc::UnboundedReceiver<()>>,
221
222    /// Streaming assistant message component (pi's `streamingComponent`).
223    /// Created on first TextDelta, updated in-place, cleared on TurnEnd/AgentEnd.
224    streaming_component:
225        Option<Weak<RefCell<crate::agent::ui::components::AssistantMessageComponent>>>,
226
227    /// Working indicator.
228    working: WorkingIndicator,
229
230    /// Transient status text (pi-style: replaces previous status, not added to chat).
231    status_text: Option<String>,
232
233    /// Pending command result that needs TUI access (overlays etc.).
234    /// Set by handle_slash_command, consumed in the main loop where TUI is available.
235    pending_command_result: Option<CommandResult>,
236
237    /// Overlay result signal — set by overlay callbacks, checked by main loop.
238    overlay_result_signal: Rc<RefCell<Option<OverlayResult>>>,
239
240    /// Pending scoped model changes from ScopedModelsSelector (session-only, no close).
241    pending_scoped_ids: Rc<RefCell<Option<Vec<String>>>>,
242
243    /// Agent tools (for tool execution).
244    /// Extensions.
245    extensions: Arc<Vec<Box<dyn Extension>>>,
246    /// Skills loaded for the session (/skill:name expansion).
247    skills: Vec<yoagent::skills::Skill>,
248    /// API key for yoagent provider.
249    api_key: String,
250    /// Session info updater for /session command.
251    session_info: Option<std::sync::Arc<std::sync::Mutex<Option<SessionInfoInternal>>>>,
252
253    /// Auto-compact toggle state.
254    auto_compact: bool,
255
256    /// Settings reference for persisting toggle changes.
257    settings: crate::agent::settings::Settings,
258
259    /// Header component (welcome/onboarding). Stored as `Rc<RefCell>` so
260    /// handle_tools_expand can toggle its expanded state (matching pi's
261    /// behavior where setToolsExpanded expands both the header and all
262    /// expandable chat children).
263    header: Rc<RefCell<crate::agent::ui::components::HeaderComponent>>,
264
265    /// Scoped model IDs for cycling (null = all enabled).
266    scoped_model_ids: Option<Vec<String>>,
267
268    /// Session picker state (Some = picker is active).
269    session_picker: Option<crate::agent::ui::components::SessionPicker>,
270
271    /// Tracks the number of children in `chat_container` after the last
272    /// status message was added (pi-style `lastStatusSpacer`/`lastStatusText`).
273    /// Used by `show_status()` to replace consecutive status messages in-place
274    /// instead of appending indefinitely.
275    last_status_len: Option<usize>,
276    // ── Message rendering cache (avoids re-rendering messages every frame) ──
277    // Cache fields removed - messages now rendered via Components in chat_container.
278}
279
280impl App {
281    fn new(config: AppConfig, session: AgentSession) -> Self {
282        let mut agent_session = session;
283        let model_config = config
284            .registry
285            .resolve(&config.model, Some(&config.provider))
286            .ok()
287            .map(|r| r.model_config.clone())
288            .unwrap_or_else(|| {
289                let mut mc = crate::agent::base_model_config(&config.model);
290                mc.context_window =
291                    crate::agent::compaction::get_model_context_window(&config.model) as u32;
292                mc
293            });
294        agent_session.set_compaction_config(
295            config.api_key.clone(),
296            &config.model,
297            crate::agent::compaction::get_model_context_window(&config.model),
298            Some(model_config),
299        );
300        agent_session.set_registry(config.registry.clone());
301        agent_session.set_auto_compact(config.settings.auto_compact.unwrap_or(true));
302        let (tx, rx) = mpsc::unbounded_channel();
303        use crate::agent::ui::theme::current_theme;
304        let theme = current_theme().clone();
305
306        let mut editor = ChatEditor::new(&theme, config.cwd.clone());
307
308        // Collect slash commands with argument completion callbacks
309        use crate::tui::autocomplete::AutocompleteItem as AutoAutocompleteItem;
310        use crate::tui::autocomplete::SlashCommand as AutoSlashCommand;
311        let auto_commands: Vec<AutoSlashCommand> = config
312            .extensions
313            .iter()
314            .flat_map(|e| e.commands())
315            .map(|cmd| {
316                let handler = cmd.handler;
317                AutoSlashCommand {
318                    name: cmd.name,
319                    description: Some(cmd.description),
320                    argument_hint: None,
321                    argument_completions: None,
322                    get_argument_completions: Some(std::sync::Arc::new(
323                        move |prefix: &str| -> Vec<AutoAutocompleteItem> {
324                            handler
325                                .argument_completions(prefix)
326                                .into_iter()
327                                .map(|item| AutoAutocompleteItem {
328                                    value: item.value,
329                                    label: item.label,
330                                    description: item.description,
331                                })
332                                .collect()
333                        },
334                    )),
335                }
336            })
337            .collect();
338        editor.set_slash_commands(auto_commands);
339
340        // Keep commands list for help overlay and unknown-command display.
341        let commands: Vec<(String, String)> = config
342            .extensions
343            .iter()
344            .flat_map(|e| e.commands())
345            .map(|c| (c.name, c.description))
346            .collect();
347
348        let editor = Rc::new(RefCell::new(editor));
349
350        let footer_provider = Rc::new(RefCell::new(FooterDataProvider::new(config.cwd.clone())));
351
352        let mut footer = Footer::new(
353            config.cwd.to_string_lossy().to_string(),
354            footer_provider.clone(),
355        );
356        footer.set_context_window(crate::agent::compaction::get_model_context_window(
357            &config.model,
358        ));
359
360        // Set available provider count for footer display
361        footer_provider
362            .borrow_mut()
363            .set_available_provider_count(config.registry.count_providers());
364
365        // Record initial model/thinking in session if not already present
366        // so refresh_from_session can pick them up.
367        {
368            let has_model_entry = !agent_session
369                .session()
370                .find_entries("model_change")
371                .is_empty();
372            if !has_model_entry {
373                agent_session.on_model_change(&config.provider, &config.model);
374            }
375            let has_thinking_entry = !agent_session
376                .session()
377                .find_entries("thinking_level_change")
378                .is_empty();
379            if !has_thinking_entry && let Some(ref level) = config.thinking_level {
380                agent_session.on_thinking_level_change(level);
381            }
382        }
383
384        let footer = Rc::new(RefCell::new(footer));
385
386        // Load session messages
387        let context = agent_session.session().build_session_context();
388        let history_messages = context.messages.clone();
389
390        // Startup info: context files, skills, tools (pi-style loaded resources listing)
391        let mut resource_parts: Vec<String> = Vec::new();
392        if !config.context_files.is_empty() {
393            let ctx = config.context_files.join(", ");
394            resource_parts.push(format!("Context: {}", ctx));
395        }
396        if !config.skills.is_empty() {
397            let skill_names: Vec<&str> = config.skills.iter().map(|s| s.name.as_str()).collect();
398            resource_parts.push(format!("Skills: {}", skill_names.join(", ")));
399        }
400
401        // Build chat_container from AgentMessages directly (matching pi's renderSessionContext).
402        // Adjacent toolCall content + toolResult messages are paired into single
403        // ToolExecComponent so reloaded sessions look identical to live execution.
404        let cwd_string = config.cwd.to_string_lossy().to_string();
405        let chat_container =
406            std::rc::Rc::new(std::cell::RefCell::new(crate::tui::Container::new()));
407        {
408            let mut chat = chat_container.borrow_mut();
409
410            // Startup info component
411            if !resource_parts.is_empty() {
412                chat.add_child(std::boxed::Box::new(
413                    crate::agent::ui::components::InfoMessageComponent::new(
414                        resource_parts.join("  ·  "),
415                    ),
416                ));
417            }
418
419            rebuild_chat_from_messages(
420                &mut chat,
421                &history_messages,
422                &cwd_string,
423                config.hide_thinking,
424                config.collapse_tool_output,
425                &config.extensions,
426            );
427        }
428
429        let mut result = Self {
430            cwd: config.cwd,
431            model: config.model,
432            current_provider: config.provider,
433            thinking_level: config.thinking_level,
434            system_prompt: config.system_prompt,
435            theme,
436            commands,
437            available_models: config.available_models,
438            registry: config.registry.clone(),
439            chat_container,
440            pending_tools: HashMap::new(),
441            tool_call_start_times: HashMap::new(),
442            invalidate_rxs: Vec::new(),
443            streaming_component: None,
444
445            status_section: std::rc::Rc::new(std::cell::RefCell::new(
446                crate::tui::components::DynamicLines::new(),
447            )),
448            working_section: std::rc::Rc::new(std::cell::RefCell::new(
449                crate::tui::components::DynamicLines::new(),
450            )),
451            editor,
452            event_tx: tx,
453            event_rx: rx,
454            is_streaming: false,
455            pending_submit: None,
456            pending_compact: None,
457            pending_auto_compact: false,
458            agent: None,
459            forward_handle: None,
460            oauth_join_handle: None,
461            pending_oauth_provider: None,
462            pending_command_result: None,
463            overlay_result_signal: Rc::new(RefCell::new(None)),
464            pending_scoped_ids: Rc::new(RefCell::new(None)),
465            hide_thinking: config.hide_thinking,
466            collapse_tool_output: config.collapse_tool_output,
467            tools_expanded: !config.collapse_tool_output,
468            scroll_offset: 0,
469            last_clear_time: std::time::Instant::now(),
470
471            should_quit: false,
472            pending_tool_executions: 0,
473            bash_abort_handle: None,
474            session: Some(agent_session),
475            footer,
476            footer_provider,
477            working: WorkingIndicator::new(),
478            extensions: Arc::new(config.extensions),
479
480            skills: config.skills,
481            session_info: config.session_info,
482            api_key: config.api_key,
483            scoped_model_ids: config.settings.enabled_models.clone(),
484            settings: config.settings,
485            auto_compact: true,
486            status_text: None,
487            header: Rc::new(RefCell::new(
488                crate::agent::ui::components::HeaderComponent::new(),
489            )),
490            session_picker: None,
491            last_status_len: None,
492        };
493
494        // Initial session info for /session command
495        result.update_session_info();
496
497        // Initialize footer stats and session name from session
498        if let Some(ref mut s) = result.session {
499            result.footer.borrow_mut().refresh_from_session(s.session());
500        }
501
502        result
503    }
504
505    /// Update the session info shared with CommandsExtension for /session display.
506    fn update_session_info(&self) {
507        if let Some(ref session) = self.session
508            && let Some(ref info) = self.session_info
509        {
510            let si = crate::builtin::commands::compute_session_info(session.session());
511            if let Ok(mut guard) = info.lock() {
512                *guard = Some(si);
513            }
514        }
515    }
516
517    /// Refresh git branch for footer display.
518    /// Called on AgentStart to match pi's FooterDataProvider.onBranchChange.
519    fn refresh_git_branch(&self) {
520        self.footer_provider.borrow_mut().refresh_git_branch();
521    }
522
523    /// Clear all transient session state when switching to a new session.
524    fn clear_session_state(&mut self) {
525        self.chat_container.borrow_mut().clear();
526        self.streaming_component = None;
527        self.pending_tools.clear();
528        self.tool_call_start_times.clear();
529        self.pending_submit = None;
530    }
531
532    /// Rebuild chat and agent messages from the current session context.
533    /// Used after compaction to update the UI and keep the agent in sync.
534    fn rebuild_from_session_context(&mut self) {
535        if let Some(ref agent_session) = self.session {
536            let context = agent_session.session().build_session_context();
537            {
538                let mut chat = self.chat_container.borrow_mut();
539                rebuild_chat_from_messages(
540                    &mut chat,
541                    &context.messages,
542                    &self.cwd.to_string_lossy(),
543                    self.hide_thinking,
544                    self.collapse_tool_output,
545                    &self.extensions,
546                );
547            }
548            if let Some(ref mut agent) = self.agent {
549                agent.replace_messages(context.messages);
550            }
551        }
552    }
553
554    /// Record a model change in the session and refresh footer display.
555    fn record_model_change(&mut self, model: &str) {
556        if let Some(ref mut agent_session) = self.session {
557            agent_session.on_model_change(&self.current_provider, model);
558        }
559        if let Some(ref session) = self.session {
560            self.footer
561                .borrow_mut()
562                .refresh_from_session(session.session());
563        }
564    }
565
566    /// Reload the provider registry from disk, updating `self.registry`.
567    /// Shows a status message on failure.
568    fn refresh_registry(&mut self) {
569        match provider::ProviderRegistry::load(&provider::get_agent_dir()) {
570            Ok(new_reg) => self.registry = Arc::new(new_reg),
571            Err(e) => {
572                self.status_text = Some(format!("Failed to refresh registry: {}", e));
573            }
574        }
575    }
576
577    /// Propagate `hide_thinking` to all chat container children and the streaming component.
578    fn propagate_hide_thinking(&mut self) {
579        let hide = self.hide_thinking;
580        {
581            let mut chat = self.chat_container.borrow_mut();
582            for child in chat.children_mut().iter_mut() {
583                child.set_hide_thinking(hide);
584            }
585        }
586        if let Some(weak) = self.streaming_component.as_ref().and_then(|w| w.upgrade()) {
587            weak.borrow_mut().set_hide_thinking(hide);
588        }
589    }
590
591    /// Switch to a different session: open the file, clear state, rebuild chat.
592    fn switch_to_session(&mut self, new_session: AgentSession) {
593        let ctx = new_session.session().build_session_context();
594        self.clear_session_state();
595        rebuild_chat_from_messages(
596            &mut self.chat_container.borrow_mut(),
597            &ctx.messages,
598            &self.cwd.to_string_lossy(),
599            self.hide_thinking,
600            self.collapse_tool_output,
601            &self.extensions,
602        );
603        // Refresh footer cached stats for the switched-to session
604        self.footer
605            .borrow_mut()
606            .refresh_from_session(new_session.session());
607
608        self.session = Some(new_session);
609        self.agent = None;
610        self.update_session_info();
611    }
612}
613
614/// Run the interactive UI.
615pub async fn run(config: AppConfig, session: AgentSession) -> anyhow::Result<()> {
616    // Initialize theme system
617    crate::agent::ui::theme::init_theme(Some("dark"), false);
618
619    let mut term = ProcessTerminal::new();
620    let mut stdout = std::io::stdout();
621
622    // Main-screen mode (like pi) - no alternate screen, no clear.
623    // Content writes from current cursor position (after shell prompt).
624    // Terminal scrolls naturally, editor/footer appear at the bottom.
625    term.start(&mut stdout)?;
626    term.hide_cursor(&mut stdout)?;
627    term.set_color_scheme_notifications(&mut stdout, true)?;
628    crate::tui::terminal::start_stdin_reader();
629
630    let mut tui = TUI::new();
631    // Disable clear_on_shrink to avoid full redraws during streaming
632    // (content grows/shrinks frequently as pending text is flushed).
633    tui.set_clear_on_shrink(false);
634    let mut app = App::new(config, session);
635
636    // Focus the editor so it emits the cursor marker for Screen tracking
637    app.editor.borrow_mut().editor.set_focused(true);
638
639    // Set up the component tree in TUI.root (matching pi's TUI.extend(Container))
640    // Order: header → chat_container (messages) → pending → status → queued → working → editor → footer
641    tui.root.add_child(std::boxed::Box::new(
642        crate::tui::components::RcRefCellComponent(
643            app.header.clone() as Rc<RefCell<dyn Component>>,
644        ),
645    ));
646    tui.root.add_child(std::boxed::Box::new(
647        crate::tui::components::RcRefCellComponent(app.chat_container.clone()
648            as std::rc::Rc<std::cell::RefCell<dyn crate::tui::Component>>),
649    ));
650    tui.root.add_child(std::boxed::Box::new(
651        crate::tui::components::RcRefCellComponent(app.status_section.clone()
652            as std::rc::Rc<std::cell::RefCell<dyn crate::tui::Component>>),
653    ));
654    tui.root.add_child(std::boxed::Box::new(
655        crate::tui::components::RcRefCellComponent(app.working_section.clone()
656            as std::rc::Rc<std::cell::RefCell<dyn crate::tui::Component>>),
657    ));
658    tui.root
659        .add_child(std::boxed::Box::new(EditorComponent(app.editor.clone())));
660    tui.root
661        .add_child(std::boxed::Box::new(FooterComponent(app.footer.clone())));
662
663    // Initialize editor border color
664    app.editor.borrow_mut().update_border_color(
665        app.thinking_level.as_deref(),
666        &app.theme as &dyn crate::tui::Theme,
667    );
668
669    // Cache terminal dimensions to avoid expensive syscall on every frame.
670    // Only re-query when a resize event is detected or periodically.
671    let mut cols: u16 = 80;
672    let mut rows: u16 = 24;
673    let mut dirty = true; // force initial render
674
675    loop {
676        // Drain agent events FIRST so state (is_streaming, pending_auto_compact) is
677        // up-to-date before handle_input checks it. Prevents races where a terminal
678        // event arrives in the same cycle as AgentEnd — handle_input would see stale
679        // is_streaming=true and steer the message instead of starting a new turn.
680        let mut had_event = false;
681        while let Ok(event) = app.event_rx.try_recv() {
682            handle_agent_event(&mut app, event);
683            had_event = true;
684        }
685        if had_event {
686            dirty = true;
687        }
688
689        // Drain terminal events (non-blocking — stdin reader runs on a
690        // separate thread). The stdin thread is already decoupled from the
691        // main loop, so we just drain whatever has arrived since last check.
692        loop {
693            match terminal::try_recv_terminal_event() {
694                Some(terminal::TerminalEvent::Key(key)) => {
695                    // TUI overlay routing first (overlays get first crack at input)
696                    if !tui.route_input(&key) {
697                        handle_input(&mut app, &mut tui, &mut term, &key);
698                    }
699                }
700                Some(terminal::TerminalEvent::Paste(content)) => {
701                    // Route to focused overlay first (e.g. Input in settings),
702                    // fall back to the main Editor.
703                    if !tui.route_paste(&content) {
704                        app.editor.borrow_mut().editor.handle_paste(&content);
705                    }
706                }
707                Some(terminal::TerminalEvent::Resize(w, h)) => {
708                    app.editor.borrow_mut().editor.set_terminal_rows(h as usize);
709                    tui.set_dimensions(w as usize, h as usize);
710                }
711                None => break,
712            }
713            dirty = true;
714        }
715
716        // Check pending scoped model changes (session-only, from on_change callback).
717        // Pi-compatible: only set scoped models when fewer than all models are enabled.
718        // Empty list or all models = no filter (None).
719        if let Some(ids) = app.pending_scoped_ids.borrow_mut().take() {
720            let auth_count = app.registry.list_authenticated_model_ids().len();
721            if ids.is_empty() || ids.len() >= auth_count {
722                app.scoped_model_ids = None;
723            } else {
724                app.scoped_model_ids = Some(ids);
725            }
726            dirty = true;
727        }
728
729        // Check overlay result signal (set by overlay callbacks when user selects/cancels).
730        if tui.has_overlays() {
731            let result = app.overlay_result_signal.borrow_mut().take();
732            if let Some(result) = result {
733                tui.pop_overlay();
734                match result {
735                    OverlayResult::ModelSelected(full_id) => {
736                        if !full_id.is_empty() {
737                            let (provider, model_id) = full_id
738                                .split_once('/')
739                                .map(|(p, m)| (p.to_string(), m.to_string()))
740                                .unwrap_or_else(|| (String::new(), full_id.clone()));
741                            app.current_provider = provider;
742                            app.model = model_id.clone();
743                            app.record_model_change(&model_id);
744                            app.status_text = Some(format!("Model: {}", full_id));
745                        }
746                    }
747                    OverlayResult::ScopedModelsAccepted(ids) => {
748                        match ids {
749                            Some(ids)
750                                if !ids.is_empty()
751                                    && ids.len()
752                                        < app.registry.list_authenticated_model_ids().len() =>
753                            {
754                                app.scoped_model_ids = Some(ids.clone());
755                                // Persist to settings
756                                app.settings.set_enabled_models(Some(ids));
757                                if let Err(e) = app.settings.save() {
758                                    app.status_text =
759                                        Some(format!("Failed to save model scope: {}", e));
760                                } else {
761                                    app.status_text = Some("Model scope saved to settings".into());
762                                }
763                            }
764                            _ => {
765                                // All enabled or none = clear scoped models and settings
766                                app.scoped_model_ids = None;
767                                app.settings.set_enabled_models(None);
768                                if let Err(e) = app.settings.save() {
769                                    app.status_text =
770                                        Some(format!("Failed to save model scope: {}", e));
771                                } else if ids.is_some() {
772                                    app.status_text = Some("Model scope saved to settings".into());
773                                }
774                            }
775                        }
776                    }
777                    OverlayResult::ScopedModelsCancelled => {
778                        // Just close the overlay, don't persist anything.
779                    }
780                    OverlayResult::LoginAuthTypeSelected(auth_type) => {
781                        // User selected auth type — show provider selector filtered by type
782                        show_login_provider_selector(&mut app, &mut tui, Some(auth_type));
783                    }
784                    OverlayResult::LoginProviderSelected(provider_id) => {
785                        // Check if this is an OAuth provider
786                        if crate::provider::oauth::get(&provider_id).is_some() {
787                            // OAuth login flow
788                            show_oauth_login_dialog(&mut app, &mut tui, &provider_id);
789                        } else {
790                            // API key login flow
791                            show_api_key_login_dialog(&mut app, &mut tui, &provider_id);
792                        }
793                    }
794                    OverlayResult::LoginApiKeyProvided { provider, key } => {
795                        // Check for OAuth login failure prefix
796                        if let Some(err_msg) = key.strip_prefix("OAUTH_LOGIN_FAILED:") {
797                            app.status_text = Some(format!("OAuth login failed: {}", err_msg));
798                        } else {
799                            match auth::login(&provider, &key) {
800                                Ok(_) => {
801                                    app.status_text = Some(format!("Logged in to {}", provider));
802                                    app.refresh_registry();
803                                    complete_login(&mut app, &provider, AuthType::ApiKey);
804                                }
805                                Err(e) => {
806                                    app.status_text = Some(format!("Login failed: {}", e));
807                                }
808                            }
809                        }
810                    }
811                    OverlayResult::LogoutProviderSelected(provider_id) => {
812                        match auth::logout(Some(&provider_id)) {
813                            Ok(true) => {
814                                app.status_text = Some(format!("Logged out from {}", provider_id));
815                                app.refresh_registry();
816                            }
817                            Ok(false) => {
818                                app.status_text =
819                                    Some(format!("No credentials for {}", provider_id));
820                            }
821                            Err(e) => {
822                                app.status_text = Some(format!("Logout failed: {}", e));
823                            }
824                        }
825                    }
826                }
827            }
828            dirty = true;
829        }
830
831        // Re-drain agent events that arrived during terminal event processing.
832        // AgentEnd (which sets is_streaming=false) can land between the initial
833        // drain above and the user hitting Enter — processing terminal events
834        // can take real time (edit operations, overlays, etc). Without this,
835        // submit_message may see a stale is_streaming=true and incorrectly try
836        // to steer a finished agent.
837        while let Ok(event) = app.event_rx.try_recv() {
838            handle_agent_event(&mut app, event);
839            dirty = true;
840        }
841
842        // Recover Agent state BEFORE submitting any new prompt or running
843        // auto-compact. This ensures agent.finish() restores messages from
844        // the completed JoinHandle first, so that subsequent
845        // replace_messages calls (from handle_auto_compact) don't get
846        // overwritten.
847        if app.forward_handle.as_ref().is_some_and(|h| h.is_finished()) {
848            app.forward_handle.take();
849            if let Some(ref mut agent) = app.agent {
850                // The JoinHandle is resolved, so this returns instantly.
851                agent.finish().await;
852            }
853        }
854
855        // Clean up completed OAuth handle
856        if app
857            .oauth_join_handle
858            .as_ref()
859            .is_some_and(|h| h.is_finished())
860        {
861            app.oauth_join_handle.take();
862
863            // OAuth task finished — check if credentials were saved and if so,
864            // refresh registry and auto-select a model (matching API key login flow).
865            // Also add a persistent chat message so the user sees the result
866            // even after the status bar text gets overwritten.
867            let oauth_provider = app.pending_oauth_provider.take();
868            if let Some(ref provider_id) = oauth_provider
869                && let Ok(Some(auth::AuthCredential::Oauth { .. })) =
870                    auth::read_credential(provider_id)
871            {
872                let provider_name = app
873                    .registry
874                    .list_providers()
875                    .into_iter()
876                    .find(|(id, _)| id == provider_id)
877                    .map(|(_, name)| name)
878                    .unwrap_or_else(|| provider_id.clone());
879                let msg = format!("✓ Logged in to {} via OAuth", provider_name);
880                app.status_text = Some(msg.clone());
881                chat_info(&mut app, &msg);
882                app.refresh_registry();
883                complete_login(
884                    &mut app,
885                    provider_id,
886                    crate::agent::ui::components::oauth_selector::AuthType::OAuth,
887                );
888            } else if oauth_provider.is_some() {
889                // OAuth task finished but no credential saved (login failed).
890                // The error message was already shown as status_text; persist it to chat.
891                let err_msg = app.status_text.clone().unwrap_or_default();
892                if !err_msg.is_empty() {
893                    chat_info(&mut app, &err_msg);
894                }
895            }
896        }
897
898        // Handle pending agent submission (async).
899        // During streaming, submit_message uses agent.steer() directly so
900        // pending_submit is only set for the idle path. Processed here as
901        // soon as is_streaming becomes false.
902        if !app.is_streaming
903            && let Some(text) = app.pending_submit.take()
904        {
905            start_agent_loop(&mut app, text).await;
906            dirty = true;
907        }
908
909        // Handle pending manual compaction (async)
910        if let Some(custom_instructions) = app.pending_compact.take() {
911            handle_compact_command(&mut app, custom_instructions).await;
912            dirty = true;
913        }
914
915        // Pi-compatible: auto-compaction check after agent ends.
916        // Runs after agent.finish() to ensure replace_messages in
917        // handle_auto_compact doesn't get overwritten.
918        if app.pending_auto_compact {
919            app.pending_auto_compact = false;
920            handle_auto_compact(&mut app).await;
921            dirty = true;
922        }
923
924        // Handle pending command results that need TUI access (overlays, etc.)
925        if let Some(result) = app.pending_command_result.take() {
926            match result {
927                CommandResult::ShowHelp => {
928                    show_help_overlay(&mut app, &mut tui);
929                }
930                CommandResult::OpenSessionSelector => {
931                    // Open session picker
932                    let mut picker = crate::agent::ui::components::SessionPicker::new();
933                    let repo = crate::agent::DefaultSessionRepo::new();
934                    picker.load_sessions(&repo);
935                    app.session_picker = Some(picker);
936                    app.status_text = None;
937                }
938                CommandResult::OpenModelSelector => {
939                    open_model_selector(&mut app, &mut tui);
940                }
941                CommandResult::OpenSettings => {
942                    chat_info(&mut app, "Settings menu - not yet implemented.");
943                }
944                CommandResult::ScopedModels => {
945                    open_scoped_models_selector(&mut app, &mut tui);
946                }
947                CommandResult::Login {
948                    ref provider,
949                    ref api_key,
950                } => {
951                    if let (Some(provider), Some(key)) = (provider, api_key) {
952                        handle_login(&mut app, provider, Some(key));
953                    } else if let Some(provider) = provider {
954                        // Provider specified, no key — show API key prompt
955                        show_api_key_login_dialog(&mut app, &mut tui, provider);
956                    } else {
957                        // No provider — determine if auth type selector is needed
958                        show_auth_type_or_provider_selector(&mut app, &mut tui);
959                    }
960                }
961                CommandResult::Logout { provider } => match provider {
962                    Some(p) => handle_logout(&mut app, Some(&p)),
963                    None => show_logout_provider_selector(&mut app, &mut tui),
964                },
965                _ => {}
966            }
967            dirty = true;
968        }
969
970        // Poll async invalidation receivers (edit tool preview, etc.)
971        app.invalidate_rxs.retain_mut(|rx| {
972            if rx.try_recv().is_ok() {
973                dirty = true;
974                true
975            } else {
976                !rx.is_closed()
977            }
978        });
979
980        // Check terminal size only when we're about to render
981        // (avoids expensive ioctl syscall on idle frames)
982        if dirty && let Ok((w, h)) = term.size() {
983            app.editor.borrow_mut().editor.set_terminal_rows(h as usize);
984            cols = w;
985            rows = h;
986        }
987
988        // Tick the working indicator - sets dirty when spinner advances
989        if app.working.tick() {
990            dirty = true;
991        }
992
993        // Tick active tool timers (bash elapsed display, matching pi's setInterval(1000))
994        let mut tools_to_remove: Vec<String> = Vec::new();
995        for (id, weak) in app.pending_tools.iter() {
996            if let Some(comp) = weak.upgrade() {
997                if comp.borrow_mut().tick_timer() {
998                    dirty = true;
999                }
1000            } else {
1001                tools_to_remove.push(id.clone());
1002            }
1003        }
1004        for id in tools_to_remove {
1005            app.pending_tools.remove(&id);
1006        }
1007
1008        // Compose and render only when state has changed
1009        if dirty {
1010            // Update section components from compose_ui
1011            compose_ui(&mut app, cols as usize);
1012            tui.set_dimensions(cols as usize, rows as usize);
1013            tui.render(cols as usize, rows as usize, &mut stdout)?;
1014            dirty = false;
1015        }
1016
1017        // Idle backpressure: sleep briefly so we don't busy-wait when idle.
1018        // Active frames (dirty, streaming, working spinner) run at ~60fps;
1019        // idle frames pace at ~20fps to save CPU/battery.
1020        tokio::time::sleep(if dirty || app.is_streaming || app.working.should_show() {
1021            Duration::from_millis(16)
1022        } else {
1023            Duration::from_millis(50)
1024        })
1025        .await;
1026
1027        // Pi: clear transient status after rendering
1028        app.status_text = None;
1029
1030        if app.should_quit {
1031            // Abort any in-flight OAuth login task
1032            if let Some(handle) = app.oauth_join_handle.take() {
1033                handle.abort();
1034            }
1035            break;
1036        }
1037    }
1038
1039    // Cleanup - move cursor past all rendered content so the shell prompt
1040    // appears on a fresh line after the footer (matching pi's stop() behavior).
1041    tui.finalize(&mut stdout)?;
1042    term.set_color_scheme_notifications(&mut stdout, false)?;
1043    term.show_cursor(&mut stdout)?;
1044    term.stop(&mut stdout)?;
1045
1046    Ok(())
1047}
1048
1049/// Update UI section components from app state.
1050/// Each section is a child of TUI.root rendered in the correct order.
1051///
1052/// Layout (top to bottom):
1053///   header → chat_container (messages) → pending → status → queued → working → editor → footer
1054fn compose_ui(app: &mut App, width: usize) {
1055    // ── Session picker ──
1056    if let Some(ref picker) = app.session_picker {
1057        let (_lines, _cursor_y) = picker.render(width, &app.theme as &dyn crate::tui::Theme);
1058        // Clear chat container when picker is active
1059        app.chat_container.borrow_mut().clear();
1060        app.status_section.borrow_mut().set_lines(vec![]);
1061        app.working_section.borrow_mut().set_lines(vec![]);
1062        return;
1063    }
1064
1065    // ── Transient status text (pi-style: replaces previous status, not added to chat) ──
1066    let mut status_lines = Vec::new();
1067    if let Some(ref status) = app.status_text {
1068        let line = app.theme.fg_key(ThemeKey::Dim, &format!(" {}", status));
1069        status_lines.push(crate::agent::ui::render_utils::pad_to_width(&line, width));
1070    }
1071
1072    // ── Queued message indicator (pi-style: shows queued messages during streaming) ──
1073    if app.is_streaming {
1074        // Show pending_submit if set (idle path, before agent loop starts)
1075        if let Some(ref msg) = app.pending_submit {
1076            let preview = if msg.len() > 60 {
1077                format!("{}…", &msg[..60])
1078            } else {
1079                msg.clone()
1080            };
1081            let line = app
1082                .theme
1083                .fg_key(ThemeKey::Dim, &format!(" 📝 queued: {}", preview));
1084            status_lines.push(crate::agent::ui::render_utils::pad_to_width(&line, width));
1085        }
1086    }
1087    app.status_section.borrow_mut().set_lines(status_lines);
1088
1089    // ── Working indicator (pi-style: blank line + spinner before editor) ──
1090    let mut working_lines = Vec::new();
1091    let wl = app.working.render(width);
1092    working_lines.extend(wl);
1093    app.working_section.borrow_mut().set_lines(working_lines);
1094}
1095
1096// Helper: create an AgentMessage for a user text input (used for steer/follow_up).
1097fn user_agent_message(text: &str) -> yoagent::types::AgentMessage {
1098    yoagent::types::AgentMessage::Llm(yoagent::types::Message::User {
1099        content: vec![yoagent::types::Content::Text {
1100            text: text.to_string(),
1101        }],
1102        timestamp: yoagent::types::now_ms(),
1103    })
1104}
1105
1106/// Handle keyboard input. Mirrors pi's InteractiveMode key dispatch:
1107///
1108/// 1. Overlays handled via TUI.route_input - checked first in event loop
1109/// 2. ChatEditor::handle_input checks app-level keys and returns InputAction
1110/// 3. App.rs matches on InputAction to perform side effects
1111///
1112/// This keeps text-editing logic in the Editor component (via ChatEditor)
1113/// and app-level side effects (aborting agents, toggling settings, etc.) here.
1114fn handle_input(app: &mut App, tui: &mut TUI, term: &mut ProcessTerminal, key: &KeyEvent) {
1115    // ── Session picker input handling ──
1116    if app.session_picker.is_some() {
1117        handle_session_picker_input(app, key);
1118        return;
1119    }
1120
1121    // ── Check if any TUI overlay is active (help, model selector, etc.) ──
1122    // Only pop when Escape is pressed and no overlay consumed it.
1123    // Overlay components return false for Escape which reaches here —
1124    // we pop the overlay instead of routing to the editor.
1125    if tui.has_overlays() && matches!(key.code, crossterm::event::KeyCode::Esc) {
1126        tui.pop_overlay();
1127        return;
1128    }
1129    if tui.has_overlays() {
1130        // Overlay didn't handle this key and it's not Escape — just ignore.
1131        return;
1132    }
1133
1134    // ── Route input to root container children (header, etc.) ──
1135    // Root children (header → chat_container → pending → etc.) get a chance
1136    // to handle input before the editor. Components that don't consume the
1137    // event return false so it flows through to the editor.
1138    if tui.root.handle_input(key) {
1139        return;
1140    }
1141
1142    // ── Dispatch to ChatEditor (mirrors pi's CustomEditor.handleInput) ──
1143    // Borrow the editor in a let binding so the RefMut drops before we mutate App.
1144    let action = app.editor.borrow_mut().handle_input(key);
1145    match action {
1146        InputAction::Handled => {}
1147        InputAction::Escape => {
1148            // Pi-style: abort streaming or bash, else clear editor
1149            if app.is_streaming {
1150                interrupt_streaming(app);
1151            } else {
1152                app.editor.borrow_mut().editor.set_text("");
1153            }
1154        }
1155        InputAction::Clear => {
1156            handle_clear(app);
1157        }
1158        InputAction::Exit => {
1159            app.should_quit = true;
1160        }
1161        InputAction::ThinkingCycle => {
1162            handle_thinking_cycle(app);
1163        }
1164        InputAction::ModelSelector => {
1165            open_model_selector(app, tui);
1166        }
1167        InputAction::ModelCycleForward => {
1168            handle_model_cycle(app, 1);
1169        }
1170        InputAction::ModelCycleBackward => {
1171            handle_model_cycle(app, -1);
1172        }
1173        InputAction::ToggleThinking => {
1174            app.hide_thinking = !app.hide_thinking;
1175            // Propagate to ALL existing components in chat container (matching pi)
1176            app.propagate_hide_thinking();
1177            // Persist only the affected field (incremental save)
1178            app.settings.set_hide_thinking(Some(app.hide_thinking));
1179            if let Err(e) = app.settings.save() {
1180                app.status_text = Some(format!("Failed to save thinking visibility: {}", e));
1181            }
1182            show_status(
1183                app,
1184                if app.hide_thinking {
1185                    "Thinking blocks: hidden".to_string()
1186                } else {
1187                    "Thinking blocks: visible".to_string()
1188                },
1189            );
1190        }
1191        InputAction::ToolsExpand => {
1192            handle_tools_expand(app);
1193        }
1194        InputAction::EditorExternal => {
1195            handle_editor_external(app, tui, term);
1196        }
1197        InputAction::Help => {
1198            show_help_overlay(app, tui);
1199        }
1200        InputAction::Submit(text) => {
1201            submit_message(app, text);
1202        }
1203        InputAction::FollowUp(text) => {
1204            handle_follow_up(app, text);
1205        }
1206        InputAction::Dequeue => {
1207            // Restore queued message back to editor (pi's app.message.dequeue)
1208            if let Some(msg) = app.pending_submit.take() {
1209                app.editor.borrow_mut().editor.set_text(&msg);
1210                app.status_text = Some("Queued message restored to editor".into());
1211            } else {
1212                app.status_text = Some("No queued message".into());
1213            }
1214        }
1215        InputAction::CompactToggle => {
1216            handle_compact_toggle(app);
1217        }
1218    }
1219}
1220
1221// =============================================================================
1222// New action handlers (pi-compatible)
1223// =============================================================================
1224
1225/// Handle Ctrl+C: clear editor (double-press within 500ms = exit).
1226fn handle_clear(app: &mut App) {
1227    let now = std::time::Instant::now();
1228    let elapsed = now.duration_since(app.last_clear_time);
1229    app.last_clear_time = now;
1230
1231    if app.is_streaming {
1232        interrupt_streaming(app);
1233    } else if elapsed.as_millis() < 500 {
1234        // Double Ctrl+C within 500ms = exit (pi-style)
1235        app.should_quit = true;
1236    } else {
1237        app.editor.borrow_mut().editor.set_text("");
1238        app.status_text = Some("Cleared".into());
1239    }
1240}
1241
1242/// Cycle thinking level through the levels available for the current model.
1243fn handle_thinking_cycle(app: &mut App) {
1244    if app.available_models.is_empty() && app.model.is_empty() {
1245        app.status_text = Some("No model selected".into());
1246        return;
1247    }
1248
1249    let levels = available_thinking_levels(app);
1250    if levels.is_empty() {
1251        return;
1252    }
1253
1254    let current = app.thinking_level.as_deref().unwrap_or("off");
1255    let next = match levels.iter().position(|&l| l == current) {
1256        Some(pos) => levels[(pos + 1) % levels.len()],
1257        None => "off",
1258    };
1259
1260    app.thinking_level = Some(next.to_string());
1261    app.editor
1262        .borrow_mut()
1263        .update_border_color(Some(next), &app.theme as &dyn crate::tui::Theme);
1264    app.settings
1265        .set_default_thinking_level(Some(next.to_string()));
1266    if let Err(e) = app.settings.save() {
1267        app.status_text = Some(format!("Failed to save thinking level: {}", e));
1268    }
1269    // Record the change in the session and refresh footer
1270    if let Some(ref mut agent_session) = app.session {
1271        agent_session.on_thinking_level_change(next);
1272    }
1273    if let Some(ref s) = app.session {
1274        app.footer.borrow_mut().refresh_from_session(s.session());
1275    }
1276    show_status(app, format!("Thinking level: {}", next));
1277}
1278
1279/// Cycle model forward (dir=1) or backward (dir=-1).
1280/// If scoped models are set, cycles through those only (matching pi's cycleModel).
1281fn handle_model_cycle(app: &mut App, dir: isize) {
1282    // Determine the model pool: scoped models if set, otherwise all available
1283    // from authenticated providers.
1284    let authenticated_models = app.registry.list_authenticated_model_ids();
1285    let model_pool: Vec<String> = if let Some(ref scoped) = app.scoped_model_ids
1286        && !scoped.is_empty()
1287    {
1288        // Scoped model IDs are "provider/id" — extract just the model id part.
1289        // We match against app.model (which is just a model id string).
1290        scoped
1291            .iter()
1292            .filter_map(|full_id| {
1293                let (_provider, model_id) = full_id.split_once('/')?;
1294                if authenticated_models.iter().any(|m| m == model_id) {
1295                    Some(model_id.to_string())
1296                } else {
1297                    None
1298                }
1299            })
1300            .collect()
1301    } else {
1302        authenticated_models
1303    };
1304
1305    let n = model_pool.len();
1306    if n == 0 {
1307        app.status_text = Some("No models available".into());
1308        return;
1309    }
1310
1311    let current_idx = model_pool.iter().position(|m| m == &app.model);
1312
1313    let next_idx = match current_idx {
1314        Some(idx) => (idx as isize + dir).rem_euclid(n as isize) as usize,
1315        None => 0,
1316    };
1317
1318    let model = model_pool[next_idx].clone();
1319    app.model = model.clone();
1320    app.current_provider = app
1321        .registry
1322        .provider_for_model(&model, Some(&app.current_provider))
1323        .unwrap_or_default();
1324    app.record_model_change(&model);
1325    show_status(app, format!("Model: {}", app.model));
1326}
1327
1328/// Toggle all tool output expansion (Ctrl+O).
1329/// Mirrors pi's `toggleToolOutputExpansion()` which iterates all chat_container
1330/// children and calls `setExpanded()` on `Expandable` components.
1331fn handle_tools_expand(app: &mut App) {
1332    app.tools_expanded = !app.tools_expanded;
1333    app.collapse_tool_output = !app.tools_expanded;
1334
1335    // Expand/collapse header (welcome/onboarding) - matching pi's setToolsExpanded
1336    // which expands both the active header and all expandable chat children.
1337    app.header.borrow_mut().set_expanded(app.tools_expanded);
1338
1339    // Propagate to all children in chat_container
1340    let mut chat = app.chat_container.borrow_mut();
1341    for child in chat.children_mut().iter_mut() {
1342        child.set_expanded(app.tools_expanded);
1343    }
1344    drop(chat);
1345
1346    app.settings
1347        .set_collapse_tool_output(Some(app.collapse_tool_output));
1348    if let Err(e) = app.settings.save() {
1349        app.status_text = Some(format!("Failed to save tool output setting: {}", e));
1350    }
1351    show_status(
1352        app,
1353        if app.tools_expanded {
1354            "Tool output: expanded".to_string()
1355        } else {
1356            "Tool output: collapsed".to_string()
1357        },
1358    );
1359}
1360
1361/// Open external editor ($VISUAL / $EDITOR) for current editor content.
1362/// Suspends the TUI (disables raw mode), runs the editor, then resumes.
1363fn handle_editor_external(app: &mut App, tui: &mut TUI, term: &mut ProcessTerminal) {
1364    let editor_cmd = std::env::var("VISUAL")
1365        .or_else(|_| std::env::var("EDITOR"))
1366        .unwrap_or_default();
1367
1368    if editor_cmd.is_empty() {
1369        app.status_text = Some("No editor configured. Set $VISUAL or $EDITOR.".into());
1370        return;
1371    }
1372
1373    let tmp_dir = std::env::temp_dir();
1374    let tmp_file = tmp_dir.join(format!(
1375        "rab-editor-{}.md",
1376        std::time::SystemTime::now()
1377            .duration_since(std::time::UNIX_EPOCH)
1378            .map(|d| d.as_nanos())
1379            .unwrap_or(0)
1380    ));
1381
1382    let current_text = app.editor.borrow().editor.get_text();
1383    if let Err(e) = std::fs::write(&tmp_file, &current_text) {
1384        app.status_text = Some(format!("Failed to write temp file: {}", e));
1385        return;
1386    }
1387
1388    let parts: Vec<&str> = editor_cmd.split(' ').collect();
1389    let (editor, args) = parts.split_first().unwrap_or((&"", &[]));
1390
1391    // ── Suspend TUI ──
1392    app.status_text = Some(format!("Opening {} ...", editor_cmd));
1393    let mut suspend_buf = Vec::new();
1394    let _ = term.stop(&mut suspend_buf);
1395    let _ = term.show_cursor(&mut suspend_buf);
1396    if !suspend_buf.is_empty() {
1397        let stdout = std::io::stdout();
1398        let mut handle = stdout.lock();
1399        let _ = handle.write_all(&suspend_buf);
1400        let _ = handle.flush();
1401    }
1402
1403    // Stop the stdin reader thread (uses poll() with timeout, exits cleanly).
1404    crate::tui::terminal::stop_stdin_reader();
1405    crate::tui::terminal::join_stdin_reader();
1406
1407    // ── Run editor ──
1408    let status = std::process::Command::new(editor)
1409        .args(args)
1410        .arg(&tmp_file)
1411        .status();
1412
1413    // ── Resume TUI ──
1414    let mut resume_buf = Vec::new();
1415    let _ = term.start(&mut resume_buf);
1416    let _ = term.hide_cursor(&mut resume_buf);
1417    if !resume_buf.is_empty() {
1418        let stdout = std::io::stdout();
1419        let mut handle = stdout.lock();
1420        let _ = handle.write_all(&resume_buf);
1421        let _ = handle.flush();
1422    }
1423    // Restart stdin reader (after raw mode is active)
1424    crate::tui::terminal::start_stdin_reader();
1425    // Force full redraw
1426    tui.request_render();
1427
1428    match status {
1429        Ok(status) if status.success() => {
1430            if let Ok(new_content) = std::fs::read_to_string(&tmp_file) {
1431                let trimmed = new_content.trim_end_matches('\n').to_string();
1432                app.editor.borrow_mut().editor.set_text(&trimmed);
1433                app.editor.borrow_mut().check_autocomplete();
1434            }
1435            let _ = std::fs::remove_file(&tmp_file);
1436            app.status_text = Some("Editor closed".into());
1437        }
1438        Ok(_) => {
1439            let _ = std::fs::remove_file(&tmp_file);
1440            app.status_text = Some("Editor exited with non-zero status".into());
1441        }
1442        Err(e) => {
1443            let _ = std::fs::remove_file(&tmp_file);
1444            app.status_text = Some(format!("Failed to launch editor: {}", e));
1445        }
1446    }
1447}
1448
1449/// Toggle auto-compact indicator (Ctrl+Shift+C).
1450/// Pi-compatible: syncs with AgentSession and persists to settings.
1451fn handle_compact_toggle(app: &mut App) {
1452    app.auto_compact = !app.auto_compact;
1453    app.footer.borrow_mut().set_auto_compact(app.auto_compact);
1454
1455    // Sync with AgentSession (pi-compatible: compaction_settings.enabled)
1456    if let Some(ref mut s) = app.session {
1457        s.set_auto_compact(app.auto_compact);
1458    }
1459
1460    // Persist to settings
1461    app.settings.set_auto_compact(Some(app.auto_compact));
1462    if let Err(e) = app.settings.save() {
1463        eprintln!("Warning: failed to save auto_compact setting: {}", e);
1464    }
1465
1466    app.status_text = Some(if app.auto_compact {
1467        "Auto-compact: on".into()
1468    } else {
1469        "Auto-compact: off".into()
1470    });
1471}
1472
1473/// Queue a follow-up message (Alt+Enter) during streaming.
1474/// Uses yoagent's native `follow_up()` — the agent loop's outer loop
1475/// picks it up naturally after the current inner loop finishes.
1476pub fn handle_follow_up(app: &mut App, text: String) {
1477    let trimmed = text.trim().to_string();
1478    if trimmed.is_empty() {
1479        return;
1480    }
1481
1482    if app.is_streaming && app.agent.as_ref().is_some_and(|a| a.is_streaming()) {
1483        let follow_msg = user_agent_message(&trimmed);
1484        if let Some(ref agent) = app.agent {
1485            agent.follow_up(follow_msg);
1486            app.status_text = Some("Follow-up queued — will send when agent finishes".into());
1487        }
1488    } else {
1489        // Not streaming — submit directly
1490        if app.is_streaming {
1491            app.is_streaming = false;
1492        }
1493        submit_message(app, trimmed);
1494    }
1495}
1496
1497/// Interrupt streaming agent and restore queued messages to editor.
1498fn interrupt_streaming(app: &mut App) {
1499    // Cooperatively cancel the running agent loop (fires cancel token)
1500    if let Some(ref agent) = app.agent {
1501        agent.abort();
1502    }
1503    // Kill the forwarding task
1504    if let Some(handle) = app.forward_handle.take() {
1505        handle.abort();
1506    }
1507    if let Some(handle) = app.bash_abort_handle.take() {
1508        handle.abort();
1509    }
1510    // Drop the agent — its tools were moved into the aborted loop and are lost.
1511    // A fresh agent will be created from session on the next turn.
1512    app.agent = None;
1513    app.is_streaming = false;
1514    app.working.stop();
1515    app.footer.borrow_mut().set_streaming(false);
1516
1517    // Rebuild chat from session (authoritative store after abort)
1518    if let Some(ref s) = app.session {
1519        let ctx = s.session().build_session_context();
1520        let mut chat = app.chat_container.borrow_mut();
1521        rebuild_chat_from_messages(
1522            &mut chat,
1523            &ctx.messages,
1524            &app.cwd.to_string_lossy(),
1525            app.hide_thinking,
1526            app.collapse_tool_output,
1527            &app.extensions,
1528        );
1529    }
1530
1531    app.status_text = Some("Interrupted".into());
1532}
1533
1534// ── Auth helpers ─────────────────────────────────────
1535
1536/// Handle a login command result. If `api_key` is provided, stores it immediately
1537/// and performs post-login completion (model auto-selection, registry refresh).
1538fn handle_login(app: &mut App, provider: &str, api_key: Option<&str>) {
1539    let provider = if provider.is_empty() {
1540        "opencode-go"
1541    } else {
1542        provider
1543    };
1544    if let Some(key) = api_key {
1545        match auth::login(provider, key) {
1546            Ok(_) => {
1547                app.refresh_registry();
1548                // Post-login completion
1549                complete_login(
1550                    app,
1551                    provider,
1552                    crate::agent::ui::components::oauth_selector::AuthType::ApiKey,
1553                );
1554            }
1555            Err(e) => chat_info(app, format!("Login failed: {}", e)),
1556        }
1557    } else {
1558        chat_info(app, format!("Usage: /login {} <api-key>", provider));
1559    }
1560}
1561
1562/// Handle a logout command result.
1563fn handle_logout(app: &mut App, provider: Option<&str>) {
1564    match auth::logout(provider) {
1565        Ok(true) => {
1566            let msg = provider
1567                .map(|p| format!("Logged out from {}", p))
1568                .unwrap_or_else(|| "Logged out from all providers".into());
1569            chat_info(app, msg);
1570        }
1571        Ok(false) => {
1572            let msg = provider
1573                .map(|p| format!("No credentials for {}", p))
1574                .unwrap_or_else(|| "No credentials found".into());
1575            chat_info(app, msg);
1576        }
1577        Err(e) => {
1578            chat_info(app, format!("Logout failed: {}", e));
1579        }
1580    }
1581}
1582
1583/// Show the login provider selector overlay, optionally filtered by auth type.
1584/// Shows all available providers from the registry for the user to pick one.
1585fn show_login_provider_selector(app: &mut App, tui: &mut TUI, auth_type: Option<AuthType>) {
1586    use crate::agent::ui::components::oauth_selector::{
1587        AuthSelectorProvider, AuthType, OAuthSelector, SelectorMode,
1588    };
1589
1590    let all_providers = app.registry.list_providers();
1591
1592    // Build the provider list, including OAuth providers from the OAuth registry
1593    let mut providers: Vec<AuthSelectorProvider> = Vec::new();
1594
1595    // Add API key providers
1596    for (id, name) in all_providers {
1597        let is_oauth_provider = crate::provider::oauth::get(&id).is_some();
1598        match auth_type {
1599            Some(AuthType::ApiKey) => {
1600                // Skip OAuth-only providers (those not in models.json)
1601                if !is_oauth_provider {
1602                    providers.push(AuthSelectorProvider {
1603                        id,
1604                        name,
1605                        auth_type: AuthType::ApiKey,
1606                    });
1607                }
1608            }
1609            Some(AuthType::OAuth) => {
1610                // Only include OAuth providers
1611                if is_oauth_provider {
1612                    providers.push(AuthSelectorProvider {
1613                        id,
1614                        name,
1615                        auth_type: AuthType::OAuth,
1616                    });
1617                }
1618            }
1619            None => {
1620                providers.push(AuthSelectorProvider {
1621                    id,
1622                    name,
1623                    auth_type: if is_oauth_provider {
1624                        AuthType::OAuth
1625                    } else {
1626                        AuthType::ApiKey
1627                    },
1628                });
1629            }
1630        }
1631    }
1632
1633    // Also add OAuth providers that aren't in models.json (e.g. only in OAuth registry)
1634    if auth_type != Some(AuthType::ApiKey) {
1635        for oauth_id in crate::provider::oauth::list_ids() {
1636            if !providers.iter().any(|p| p.id == oauth_id)
1637                && let Some(provider) = crate::provider::oauth::get(&oauth_id)
1638            {
1639                providers.push(AuthSelectorProvider {
1640                    id: oauth_id,
1641                    name: provider.name().to_string(),
1642                    auth_type: AuthType::OAuth,
1643                });
1644            }
1645        }
1646    }
1647
1648    // Sort alphabetically by name for consistent display.
1649    providers.sort_by_key(|a| a.name.to_lowercase());
1650
1651    if providers.is_empty() {
1652        app.status_text = Some(match auth_type {
1653            Some(AuthType::OAuth) => "No subscription providers available.".into(),
1654            Some(AuthType::ApiKey) => "No API key providers available.".into(),
1655            None => "No providers available.".into(),
1656        });
1657        return;
1658    }
1659
1660    let signal = app.overlay_result_signal.clone();
1661    let mut selector = OAuthSelector::new(
1662        providers,
1663        |provider_id| app.registry.auth_status_for_provider(provider_id),
1664        SelectorMode::Login,
1665    );
1666
1667    selector.on_select(move |provider_id: String| {
1668        *signal.borrow_mut() = Some(OverlayResult::LoginProviderSelected(provider_id));
1669    });
1670    selector.on_cancel(|| {});
1671
1672    tui.show_top_overlay(Box::new(selector));
1673}
1674
1675/// Show the API key input dialog for a specific provider.
1676/// Uses LoginDialog which matches pi's LoginDialogComponent.
1677fn show_api_key_login_dialog(app: &mut App, tui: &mut TUI, provider_id: &str) {
1678    use crate::agent::ui::components::LoginDialog;
1679
1680    // Find the provider name from the registry
1681    let provider_name = app
1682        .registry
1683        .list_providers()
1684        .into_iter()
1685        .find(|(id, _)| id == provider_id)
1686        .map(|(_, name)| name)
1687        .unwrap_or_else(|| provider_id.to_string());
1688
1689    let mut dialog = LoginDialog::new(provider_id.to_string(), provider_name.clone());
1690
1691    let signal = app.overlay_result_signal.clone();
1692    let provider_id_clone = provider_id.to_string();
1693
1694    dialog.on_submit(move |api_key: String| {
1695        *signal.borrow_mut() = Some(OverlayResult::LoginApiKeyProvided {
1696            provider: provider_id_clone,
1697            key: api_key,
1698        });
1699    });
1700
1701    dialog.on_cancel(|| {});
1702
1703    dialog.show_prompt("Enter API key:", Some("sk-..."));
1704
1705    tui.show_top_overlay(Box::new(dialog));
1706}
1707
1708/// Show the OAuth login dialog for a specific provider.
1709/// Matches pi's showLoginDialog for OAuth providers.
1710fn show_oauth_login_dialog(app: &mut App, tui: &mut TUI, provider_id: &str) {
1711    let provider_name = app
1712        .registry
1713        .list_providers()
1714        .into_iter()
1715        .find(|(id, _)| id == provider_id)
1716        .map(|(_, name)| name)
1717        .unwrap_or_else(|| {
1718            crate::provider::oauth::get(provider_id)
1719                .map(|p| p.name().to_string())
1720                .unwrap_or_else(|| provider_id.to_string())
1721        });
1722
1723    app.status_text = Some(format!("Starting OAuth login for {}…", provider_name));
1724    tui.pop_overlay(); // close the provider selector overlay
1725
1726    // Send progress updates through the agent event channel.
1727    // ProgressMessage with empty tool_name sets app.status_text (visible to user).
1728    let tx = app.event_tx.clone();
1729    let pid = provider_id.to_string();
1730    let pname = provider_name.clone();
1731
1732    let tx2 = tx.clone();
1733    let tx3 = tx.clone();
1734    let tx4 = tx.clone();
1735
1736    app.pending_oauth_provider = Some(pid.clone());
1737
1738    let handle = tokio::spawn(async move {
1739        let oauth_provider = match crate::provider::oauth::get(&pid) {
1740            Some(p) => p,
1741            None => {
1742                let _ = tx.send(yoagent::types::AgentEvent::ProgressMessage {
1743                    tool_call_id: String::new(),
1744                    tool_name: String::new(),
1745                    text: format!(
1746                        "OAuth login failed: No OAuth provider registered for '{}'",
1747                        pid
1748                    ),
1749                });
1750                return;
1751            }
1752        };
1753
1754        let mut callbacks = crate::provider::oauth::OAuthLoginCallbacks {
1755            on_device_code: Box::new(move |info: crate::provider::oauth::DeviceCodeInfo| {
1756                let device_msg = format!(
1757                    "Open {} and enter code: {}",
1758                    info.verification_uri, info.user_code
1759                );
1760                // Show as status AND as a persistent chat message via ToolExecutionEnd
1761                let _ = tx.send(yoagent::types::AgentEvent::ProgressMessage {
1762                    tool_call_id: String::new(),
1763                    tool_name: String::new(),
1764                    text: device_msg,
1765                });
1766            }),
1767            on_prompt: Box::new(
1768                move |prompt: crate::provider::oauth::OAuthPrompt| match prompt {
1769                    crate::provider::oauth::OAuthPrompt::Text {
1770                        message,
1771                        placeholder: _,
1772                        allow_empty: _,
1773                    } => {
1774                        // Log the prompt so users see it; empty response = default (github.com)
1775                        let _ = tx2.send(yoagent::types::AgentEvent::ProgressMessage {
1776                            tool_call_id: String::new(),
1777                            tool_name: String::new(),
1778                            text: format!("{} (empty = github.com)", message),
1779                        });
1780                        // For now, accept empty — GitHub Enterprise users need to
1781                        // set enterprise_url in credentials manually or via config.
1782                        Ok(String::new())
1783                    }
1784                },
1785            ),
1786            on_progress: Box::new(move |msg: String| {
1787                let _ = tx3.send(yoagent::types::AgentEvent::ProgressMessage {
1788                    tool_call_id: String::new(),
1789                    tool_name: String::new(),
1790                    text: format!("[OAuth] {}", msg),
1791                });
1792            }),
1793            signal: None,
1794        };
1795
1796        match oauth_provider.login(&mut callbacks).await {
1797            Ok(credentials) => {
1798                let cred = crate::auth::AuthCredential::Oauth {
1799                    access: credentials.access.clone(),
1800                    refresh: Some(credentials.refresh.clone()),
1801                    expires: Some(credentials.expires),
1802                    enterprise_url: credentials.enterprise_url.clone(),
1803                };
1804                match crate::auth::login_oauth(&pid, &cred) {
1805                    Ok(_) => {
1806                        let _ = tx4.send(yoagent::types::AgentEvent::ProgressMessage {
1807                            tool_call_id: String::new(),
1808                            tool_name: String::new(),
1809                            text: format!("✓ Logged in to {} via OAuth", pname),
1810                        });
1811                    }
1812                    Err(e) => {
1813                        let _ = tx4.send(yoagent::types::AgentEvent::ProgressMessage {
1814                            tool_call_id: String::new(),
1815                            tool_name: String::new(),
1816                            text: format!("Failed to save OAuth credentials: {}", e),
1817                        });
1818                    }
1819                }
1820            }
1821            Err(e) => {
1822                let _ = tx4.send(yoagent::types::AgentEvent::ProgressMessage {
1823                    tool_call_id: String::new(),
1824                    tool_name: String::new(),
1825                    text: format!("OAuth login failed: {}", e),
1826                });
1827            }
1828        }
1829    });
1830    app.oauth_join_handle = Some(handle);
1831}
1832
1833/// Show the auth type selector overlay ("Use a subscription" vs "Use an API key").
1834/// Matches pi's showLoginAuthTypeSelector behavior.
1835fn show_auth_type_selector(app: &mut App, tui: &mut TUI) {
1836    // Build simple two-option selector
1837    let signal = app.overlay_result_signal.clone();
1838    let _theme = crate::agent::ui::theme::current_theme().clone();
1839
1840    let mut items = vec![crate::tui::components::select_list::SelectItem::new(
1841        "api_key",
1842        "Use an API key",
1843    )];
1844    // Add OAuth option if any OAuth providers are registered
1845    let has_oauth = !crate::provider::oauth::list_ids().is_empty();
1846    if has_oauth {
1847        items.push(crate::tui::components::select_list::SelectItem::new(
1848            "oauth",
1849            "Use a subscription",
1850        ));
1851    }
1852
1853    let filtered_indices: Vec<usize> = (0..items.len()).collect();
1854    let selected_index: usize = 0;
1855
1856    struct AuthTypeOverlay {
1857        items: Vec<crate::tui::components::select_list::SelectItem>,
1858        selected_index: usize,
1859        filtered_indices: Vec<usize>,
1860        signal: std::rc::Rc<std::cell::RefCell<Option<OverlayResult>>>,
1861    }
1862
1863    impl crate::tui::Component for AuthTypeOverlay {
1864        fn render(&mut self, width: usize) -> Vec<String> {
1865            let theme = crate::agent::ui::theme::current_theme();
1866            let mut lines = Vec::new();
1867
1868            lines.push(theme.dim(&"─".repeat(width.saturating_sub(2))));
1869            lines.push(String::new());
1870            lines.push(format!(
1871                "  {}",
1872                theme.bold(&theme.fg_key(ThemeKey::Accent, "Select authentication method:"))
1873            ));
1874            lines.push(String::new());
1875
1876            for (i, &item_idx) in self.filtered_indices.iter().enumerate() {
1877                let item = &self.items[item_idx];
1878                let is_selected = i == self.selected_index;
1879                let prefix = if is_selected {
1880                    theme.fg_key(ThemeKey::Accent, "→ ")
1881                } else {
1882                    "  ".to_string()
1883                };
1884                let text = if is_selected {
1885                    theme.fg_key(ThemeKey::Accent, &item.label)
1886                } else {
1887                    theme.fg_key(ThemeKey::Text, &item.label)
1888                };
1889                lines.push(format!("{}{}", prefix, text));
1890            }
1891
1892            lines.push(String::new());
1893            lines.push(format!("  {}", theme.dim("Enter: select · Esc: cancel")));
1894            lines.push(String::new());
1895            lines.push(theme.dim(&"─".repeat(width.saturating_sub(2))));
1896
1897            lines
1898        }
1899
1900        fn handle_input(&mut self, key: &crossterm::event::KeyEvent) -> bool {
1901            let kb = crate::tui::keybindings::get_keybindings();
1902
1903            if kb.matches(key, crate::tui::keybindings::ACTION_SELECT_UP) {
1904                if self.filtered_indices.is_empty() {
1905                    return true;
1906                }
1907                self.selected_index = if self.selected_index == 0 {
1908                    self.filtered_indices.len() - 1
1909                } else {
1910                    self.selected_index - 1
1911                };
1912                return true;
1913            }
1914
1915            if kb.matches(key, crate::tui::keybindings::ACTION_SELECT_DOWN) {
1916                if self.filtered_indices.is_empty() {
1917                    return true;
1918                }
1919                self.selected_index = if self.selected_index >= self.filtered_indices.len() - 1 {
1920                    0
1921                } else {
1922                    self.selected_index + 1
1923                };
1924                return true;
1925            }
1926
1927            if kb.matches(key, crate::tui::keybindings::ACTION_SELECT_CONFIRM) {
1928                if let Some(&idx) = self.filtered_indices.get(self.selected_index) {
1929                    let value = self.items[idx].value.clone();
1930                    let auth_type = match value.as_str() {
1931                        "oauth" => AuthType::OAuth,
1932                        _ => AuthType::ApiKey,
1933                    };
1934                    *self.signal.borrow_mut() =
1935                        Some(OverlayResult::LoginAuthTypeSelected(auth_type));
1936                }
1937                return true;
1938            }
1939
1940            if kb.matches(key, crate::tui::keybindings::ACTION_SELECT_CANCEL) {
1941                // Cancel — just close overlay
1942                return true;
1943            }
1944
1945            false
1946        }
1947    }
1948
1949    let overlay = AuthTypeOverlay {
1950        items,
1951        selected_index,
1952        filtered_indices,
1953        signal: signal.clone(),
1954    };
1955
1956    tui.show_top_overlay(Box::new(overlay));
1957}
1958
1959/// Show auth type selector or go directly to provider list depending on
1960/// which auth types are available. Matches pi's logic.
1961fn show_auth_type_or_provider_selector(app: &mut App, tui: &mut TUI) {
1962    let providers = app.registry.list_providers();
1963    if providers.is_empty() {
1964        app.status_text = Some("No providers available for login.".into());
1965        return;
1966    }
1967    // Check if any OAuth providers are registered (from OAuth registry)
1968    let has_oauth = !crate::provider::oauth::list_ids().is_empty();
1969    let has_api_key = providers.iter().any(|(_, _)| true);
1970    if has_oauth && has_api_key {
1971        show_auth_type_selector(app, tui);
1972    } else if has_oauth {
1973        show_login_provider_selector(app, tui, Some(AuthType::OAuth));
1974    } else {
1975        show_login_provider_selector(app, tui, Some(AuthType::ApiKey));
1976    }
1977}
1978
1979/// Show the logout provider selector overlay.
1980/// Shows only providers with stored credentials (matching pi's getLogoutProviderOptions).
1981fn show_logout_provider_selector(app: &mut App, tui: &mut TUI) {
1982    use crate::agent::ui::components::oauth_selector::{
1983        AuthSelectorProvider, AuthType, OAuthSelector, SelectorMode,
1984    };
1985
1986    // Get providers that have stored credentials
1987    let logged_in = auth::list_logged_in().unwrap_or_default();
1988
1989    if logged_in.is_empty() {
1990        app.status_text = Some(
1991            "No stored credentials to remove. /logout only removes credentials saved by /login; \
1992             environment variables and models.json config are unchanged."
1993                .into(),
1994        );
1995        return;
1996    }
1997
1998    let mut providers: Vec<AuthSelectorProvider> = logged_in
1999        .into_iter()
2000        .filter_map(|id| {
2001            app.registry
2002                .list_providers()
2003                .into_iter()
2004                .find(|(pid, _)| pid == &id)
2005                .map(|(pid, name)| AuthSelectorProvider {
2006                    id: pid,
2007                    name,
2008                    auth_type: AuthType::ApiKey,
2009                })
2010        })
2011        .collect();
2012
2013    // Sort alphabetically by name for consistent display.
2014    providers.sort_by_key(|a| a.name.to_lowercase());
2015
2016    if providers.is_empty() {
2017        // Providers with stored credentials may not be in registry anymore
2018        app.status_text = Some("No registered providers with stored credentials.".into());
2019        return;
2020    }
2021
2022    let signal = app.overlay_result_signal.clone();
2023    let mut selector = OAuthSelector::new(
2024        providers,
2025        |provider_id| app.registry.auth_status_for_provider(provider_id),
2026        SelectorMode::Logout,
2027    );
2028
2029    selector.on_select(move |provider_id: String| {
2030        *signal.borrow_mut() = Some(OverlayResult::LogoutProviderSelected(provider_id));
2031    });
2032    selector.on_cancel(|| {});
2033
2034    tui.show_top_overlay(Box::new(selector));
2035}
2036
2037/// Post-login completion: auto-select default model for the provider.
2038/// Matches pi's completeProviderAuthentication logic for API key login.
2039fn complete_login(app: &mut App, provider_id: &str, _auth_type: AuthType) {
2040    // Try to select the default model for this provider
2041    let available_models = app.registry.list_model_provider_tuples();
2042    let provider_models: Vec<&str> = available_models
2043        .iter()
2044        .filter(|(pid, _, _)| pid == provider_id)
2045        .map(|(_, mid, _)| mid.as_str())
2046        .collect();
2047
2048    if provider_models.is_empty() {
2049        app.status_text = Some(format!(
2050            "Saved API key for {provider_id}. No models available for this provider. Use /model to select a model."
2051        ));
2052        return;
2053    }
2054
2055    // If current model is unknown or doesn't belong to this provider, select first available
2056    let current_provider = app
2057        .registry
2058        .provider_for_model(&app.model, Some(&app.current_provider))
2059        .unwrap_or_default();
2060
2061    if current_provider != provider_id || !app.available_models.contains(&app.model) {
2062        let first_model = provider_models[0];
2063        app.model = first_model.to_string();
2064        app.current_provider = provider_id.to_string();
2065        let model = app.model.clone();
2066        app.record_model_change(&model);
2067        app.status_text = Some(format!(
2068            "Saved API key for {provider_id}. Selected {first_model}."
2069        ));
2070    } else {
2071        app.status_text = Some(format!("Saved API key for {provider_id}."));
2072    }
2073}
2074
2075/// Open the model selector overlay.
2076fn open_model_selector(app: &mut App, tui: &mut TUI) {
2077    let current = app.model.clone();
2078
2079    // Build (provider, model_id, name) tuples from authenticated providers only.
2080    // This matches pi's behavior of showing only models from configured providers.
2081    let all_tuples: Vec<(String, String, String)> = app.registry.list_model_provider_tuples();
2082    let all_models: Vec<(String, String, String)> = all_tuples
2083        .into_iter()
2084        .filter(|(provider, _, _)| app.registry.provider_has_auth(provider))
2085        .collect();
2086
2087    let scoped_ids = app.scoped_model_ids.clone().unwrap_or_default();
2088
2089    let signal = app.overlay_result_signal.clone();
2090    let current_provider = app
2091        .registry
2092        .provider_for_model(&current, Some(&app.current_provider))
2093        .unwrap_or_else(|| "unknown".to_string());
2094    let current_full_id = format!("{}/{}", current_provider, current);
2095
2096    let callbacks = crate::agent::ui::model_selector::ModelSelectorCallbacks {
2097        on_select: Box::new({
2098            let signal = signal.clone();
2099            move |full_id: String| {
2100                *signal.borrow_mut() = Some(OverlayResult::ModelSelected(full_id));
2101            }
2102        }),
2103        on_cancel: Box::new(|| {}), // No-op: overlay is popped by handle_input returning false
2104    };
2105
2106    let selector = crate::agent::ui::model_selector::ModelSelector::new(
2107        all_models,
2108        scoped_ids,
2109        current_full_id,
2110        callbacks,
2111    );
2112    tui.show_top_overlay(Box::new(selector));
2113}
2114
2115/// Open the scoped-models selector overlay.
2116fn open_scoped_models_selector(app: &mut App, tui: &mut TUI) {
2117    use crate::agent::ui::components::scoped_models_selector::{
2118        ModelsCallbacks, ModelsConfig, ScopedModelsSelector,
2119    };
2120
2121    // Build (provider, model_id, name) tuples from authenticated providers only.
2122    let all_tuples: Vec<(String, String, String)> = app.registry.list_model_provider_tuples();
2123    let all_models: Vec<(String, String, String)> = all_tuples
2124        .into_iter()
2125        .filter(|(provider, _, _)| app.registry.provider_has_auth(provider))
2126        .collect();
2127
2128    let current_enabled = app.scoped_model_ids.clone();
2129    let change_signal = app.pending_scoped_ids.clone();
2130    let close_signal = app.overlay_result_signal.clone();
2131
2132    let callbacks = ModelsCallbacks {
2133        on_change: Box::new(move |enabled_ids: Option<Vec<String>>| {
2134            // Session-only update — does NOT close the overlay.
2135            *change_signal.borrow_mut() = Some(enabled_ids.unwrap_or_default());
2136        }),
2137        on_persist: Box::new({
2138            let cs = close_signal.clone();
2139            move |enabled_ids: Option<Vec<String>>| {
2140                *cs.borrow_mut() = Some(OverlayResult::ScopedModelsAccepted(enabled_ids));
2141            }
2142        }),
2143        on_cancel: Box::new(move || {
2144            *close_signal.borrow_mut() = Some(OverlayResult::ScopedModelsCancelled);
2145        }),
2146    };
2147
2148    let config = ModelsConfig {
2149        all_models,
2150        enabled_model_ids: current_enabled,
2151    };
2152
2153    let selector = ScopedModelsSelector::new(config, callbacks);
2154    tui.show_top_overlay(Box::new(selector));
2155}
2156
2157fn show_help_overlay(app: &mut App, tui: &mut TUI) {
2158    let mut overlay = crate::agent::ui::help::HelpOverlay::new(&app.theme);
2159    overlay.set_commands(app.commands.clone());
2160    tui.show_overlay(Box::new(overlay), Default::default());
2161}
2162
2163/// Submit or queue a user message.
2164/// When streaming, sets pending_submit which is deferred until the current
2165/// turn finishes (the main loop skips start_agent_loop while is_streaming).
2166/// When idle, starts a new agent loop immediately.
2167fn submit_message(app: &mut App, message: String) {
2168    app.scroll_offset = 0;
2169    let trimmed = message.trim().to_string();
2170
2171    // Don't submit empty messages (pi-style)
2172    if trimmed.is_empty() {
2173        return;
2174    }
2175
2176    // Handle /skill:name [args] expansion (pi-style: before command dispatch)
2177    if trimmed.starts_with("/skill:") {
2178        let expanded = expand_skill_command(&trimmed, &app.skills);
2179        if app.is_streaming && app.agent.as_ref().is_some_and(|a| a.is_streaming()) {
2180            let steer_msg = user_agent_message(&expanded);
2181            if let Some(ref agent) = app.agent {
2182                agent.steer(steer_msg);
2183                app.status_text = Some("Skill steering message sent".into());
2184            }
2185            return;
2186        }
2187        if app.is_streaming {
2188            // Stale streaming flag — reset
2189            app.is_streaming = false;
2190            app.working.stop();
2191            app.footer.borrow_mut().set_streaming(false);
2192        }
2193        app.pending_submit = Some(expanded);
2194        return;
2195    }
2196
2197    // Handle /commands (need TUI from app for overlays)
2198    if trimmed.starts_with('/') {
2199        handle_slash_command(app, &trimmed);
2200        return;
2201    }
2202
2203    // Handle ! and !! bang commands
2204    if let Some((cmd, _exclude)) = parse_bang_command(&trimmed) {
2205        handle_bang_command(app, cmd);
2206        return;
2207    }
2208
2209    if app.is_streaming {
2210        // When streaming, queue via steer(). The agent loop picks it up
2211        // between tool calls or after the current assistant turn, then
2212        // continues processing. Do NOT add to chat here — MessageStart
2213        // handler adds it when the agent loop processes the queued message.
2214        if app.agent.as_ref().is_some_and(|a| a.is_streaming()) {
2215            let steer_msg = user_agent_message(&trimmed);
2216            if let Some(ref agent) = app.agent {
2217                agent.steer(steer_msg);
2218                app.status_text = Some("Steering message sent — will be processed next".into());
2219            }
2220            // Reset overflow recovery for the steer'd message
2221            if let Some(ref mut s) = app.session {
2222                s.reset_overflow_recovery();
2223            }
2224            return; // Don't set pending_submit — agent loop handles this
2225        } else {
2226            // Stale streaming flag — agent task finished but is_streaming
2227            // not reset. Fall through to normal submission path.
2228            app.is_streaming = false;
2229            app.working.stop();
2230            app.footer.borrow_mut().set_streaming(false);
2231        }
2232    }
2233
2234    // Pi-compatible: reset overflow recovery state at the start of each turn
2235    if let Some(ref mut s) = app.session {
2236        s.reset_overflow_recovery();
2237    }
2238
2239    // Queue for async start in the main loop
2240    app.pending_submit = Some(trimmed);
2241}
2242
2243/// Build a fresh Agent with the given messages and app configuration.
2244/// Uses the provider registry to resolve the model and dispatch to the right provider.
2245#[allow(clippy::too_many_arguments)]
2246fn build_fresh_agent(
2247    registry: &ProviderRegistry,
2248    model: &str,
2249    api_key: &str,
2250    system_prompt: &str,
2251    thinking_level: yoagent::types::ThinkingLevel,
2252    messages: Vec<yoagent::types::AgentMessage>,
2253    extensions: &[Box<dyn Extension>],
2254    default_provider: Option<&str>,
2255) -> yoagent::agent::Agent {
2256    use yoagent::provider::model::ApiProtocol;
2257
2258    let resolved = registry.resolve(model, default_provider).ok();
2259    let mc = resolved
2260        .as_ref()
2261        .map(|r| r.model_config.clone())
2262        .unwrap_or_else(|| crate::agent::base_model_config(model));
2263    let api_key = resolved
2264        .as_ref()
2265        .map(|r| r.api_key.as_str())
2266        .unwrap_or(api_key);
2267
2268    let tools: Vec<Box<dyn yoagent::types::AgentTool>> = extensions
2269        .iter()
2270        .flat_map(|ext| ext.tools())
2271        .map(|twm| Box::new(twm) as Box<dyn yoagent::types::AgentTool>)
2272        .collect();
2273
2274    let agent = match mc.api {
2275        ApiProtocol::OpenAiCompletions => {
2276            yoagent::agent::Agent::new(crate::provider::openai_compat::RabOpenAiCompatProvider)
2277        }
2278        ApiProtocol::AnthropicMessages => {
2279            yoagent::agent::Agent::new(crate::provider::anthropic::RabAnthropicProvider)
2280        }
2281        ApiProtocol::OpenAiResponses => {
2282            yoagent::agent::Agent::new(yoagent::provider::OpenAiResponsesProvider)
2283        }
2284        ApiProtocol::GoogleGenerativeAi => {
2285            yoagent::agent::Agent::new(yoagent::provider::GoogleProvider)
2286        }
2287        _ => yoagent::agent::Agent::new(yoagent::provider::OpenAiCompatProvider),
2288    };
2289
2290    agent
2291        .with_model(model)
2292        .with_api_key(api_key)
2293        .with_model_config(mc)
2294        .with_system_prompt(system_prompt)
2295        .with_thinking(thinking_level)
2296        .with_messages(messages)
2297        .with_tools(tools)
2298        .without_context_management()
2299}
2300
2301/// Map rab's thinking level string to yoagent's ThinkingLevel enum.
2302fn map_thinking_level(level: Option<&str>) -> yoagent::types::ThinkingLevel {
2303    match level {
2304        Some("off") => yoagent::types::ThinkingLevel::Off,
2305        Some("low") => yoagent::types::ThinkingLevel::Low,
2306        Some("medium") => yoagent::types::ThinkingLevel::Medium,
2307        Some("high") | Some("xhigh") => yoagent::types::ThinkingLevel::High,
2308        _ => yoagent::types::ThinkingLevel::High,
2309    }
2310}
2311
2312/// Start an agent turn asynchronously. Called from the main loop only when
2313/// the agent is idle (the main loop guards with `!app.is_streaming`).
2314/// Reuses the existing agent across turns (single-agent model) so that
2315/// steer/follow-up queues and in-flight tool state survive across turns.
2316/// If no agent exists yet (first turn), creates a fresh one.
2317/// Messages are always synced from the session (error-filtered source) at
2318/// the start of each turn to avoid leaking transient provider errors.
2319async fn start_agent_loop(app: &mut App, message: String) {
2320    if app.session.is_none() {
2321        return;
2322    }
2323
2324    app.is_streaming = true;
2325    app.working.start();
2326    app.footer.borrow_mut().set_streaming(true);
2327
2328    let thinking = map_thinking_level(app.thinking_level.as_deref());
2329
2330    // Build or reuse agent. On the first turn the session has no messages;
2331    // on subsequent turns the reused agent already has messages restored
2332    // by agent.finish() — no need to sync from session here.
2333    let msgs = app
2334        .session
2335        .as_ref()
2336        .map(|s| s.session().build_session_context().messages)
2337        .unwrap_or_default();
2338
2339    // Record model/thinking changes in the session before borrowing agent
2340    let model = app.model.clone();
2341    app.record_model_change(&model);
2342    if let Some(ref mut session) = app.session {
2343        session.on_thinking_level_change(app.thinking_level.as_deref().unwrap_or("off"));
2344    }
2345
2346    let agent: &mut yoagent::agent::Agent = match &mut app.agent {
2347        Some(existing) => {
2348            // Reuse existing agent — messages are already correct from
2349            // agent.finish(). Compaction sync is handled separately by
2350            // handle_auto_compact / handle_compact_command.
2351            existing
2352        }
2353        None => {
2354            let preferred = if !app.current_provider.is_empty() {
2355                Some(app.current_provider.as_str())
2356            } else {
2357                app.settings.default_provider.as_deref()
2358            };
2359            app.agent = Some(build_fresh_agent(
2360                &app.registry,
2361                &app.model,
2362                &app.api_key,
2363                &app.system_prompt,
2364                thinking,
2365                msgs,
2366                &app.extensions,
2367                preferred,
2368            ));
2369            // SAFETY: we just set app.agent to Some(...)
2370            app.agent.as_mut().unwrap()
2371        }
2372    };
2373
2374    // Start the turn: agent.prompt() spawns the loop internally, keeps the
2375    // Agent in scope, and returns a receiver for streaming events.
2376    let mut rx = agent.prompt(message).await;
2377
2378    // Forward events from the agent's receiver to the UI channel.
2379    // This runs concurrently while the agent loop processes the turn.
2380    let tx = app.event_tx.clone();
2381    let handle = tokio::spawn(async move {
2382        while let Some(event) = rx.recv().await {
2383            if tx.send(event).is_err() {
2384                break;
2385            }
2386        }
2387    });
2388    app.forward_handle = Some(handle);
2389}
2390
2391/// Handle manual compaction asynchronously.
2392/// Called from the main loop when pending_compact is set.
2393async fn handle_compact_command(app: &mut App, custom_instructions: Option<String>) {
2394    if app.session.is_none() {
2395        chat_info(app, "No active session to compact".to_string());
2396        return;
2397    }
2398
2399    let agent_session = app.session.as_mut().unwrap();
2400
2401    app.working.start();
2402
2403    match agent_session
2404        .run_manual_compact(custom_instructions.as_deref())
2405        .await
2406    {
2407        Ok(_summary) => {
2408            app.working.stop();
2409            app.status_text = None;
2410            app.rebuild_from_session_context();
2411            show_status(app, "Compaction completed".to_string());
2412        }
2413        Err(e) => {
2414            app.working.stop();
2415            app.status_text = None;
2416            chat_info(app, format!("Compaction failed: {}", e));
2417        }
2418    }
2419}
2420
2421/// Pi-compatible: auto-compaction check after agent ends.
2422/// Calls `check_auto_compact()` on the session. If compaction was performed,
2423/// rebuilds the chat from the updated session context and updates agent state.
2424async fn handle_auto_compact(app: &mut App) {
2425    if app.session.is_none() {
2426        return;
2427    }
2428
2429    let agent_session = app.session.as_mut().unwrap();
2430
2431    match agent_session.check_auto_compact().await {
2432        Ok(true) => {
2433            app.rebuild_from_session_context();
2434            // Refresh footer stats (token counts may have changed)
2435            if let Some(ref s) = app.session {
2436                app.footer.borrow_mut().refresh_from_session(s.session());
2437            }
2438            app.status_text = Some("Auto-compaction completed".to_string());
2439        }
2440        Ok(false) => {
2441            // No compaction needed — nothing to do
2442        }
2443        Err(e) => {
2444            eprintln!("Warning: Auto-compaction failed: {}", e);
2445            app.status_text = Some(format!("Auto-compaction skipped: {}", e));
2446        }
2447    }
2448}
2449
2450/// Handle keyboard input for the session picker.
2451fn handle_session_picker_input(app: &mut App, key: &crossterm::event::KeyEvent) {
2452    use crossterm::event::KeyCode;
2453
2454    let Some(ref mut picker) = app.session_picker else {
2455        return;
2456    };
2457
2458    match key.code {
2459        KeyCode::Esc => {
2460            app.session_picker = None;
2461            app.status_text = None;
2462        }
2463        KeyCode::Enter => {
2464            if let Some(path) = picker.selected_path() {
2465                let path = path.clone();
2466                app.session_picker = None;
2467                app.status_text = None;
2468                // Delegate to the shared SessionSwitched handler
2469                app.pending_command_result = Some(CommandResult::SessionSwitched { path });
2470            }
2471        }
2472        KeyCode::Up => {
2473            picker.select_prev();
2474        }
2475        KeyCode::Down => {
2476            picker.select_next();
2477        }
2478        KeyCode::Char('/') => {
2479            picker.set_filter("");
2480        }
2481        KeyCode::Char(c) => {
2482            let mut filter = picker.filter().to_string();
2483            filter.push(c);
2484            picker.set_filter(&filter);
2485        }
2486        KeyCode::Backspace => {
2487            let mut filter = picker.filter().to_string();
2488            filter.pop();
2489            picker.set_filter(&filter);
2490        }
2491        _ => {}
2492    }
2493}
2494
2495/// Handle slash commands by dispatching through extension command handlers.
2496/// For commands that need TUI access (overlays), the result is stored in
2497/// `pending_command_result` and consumed in the main loop where TUI is available.
2498/// Simple results (Info, Quit, etc.) are handled immediately.
2499fn handle_slash_command(app: &mut App, input: &str) {
2500    let (cmd_name, args) = match input.split_once(' ') {
2501        Some((cmd, rest)) => (cmd.trim_start_matches('/'), rest),
2502        None => (input.trim_start_matches('/'), ""),
2503    };
2504
2505    // Find the command handler first (before mutable borrow on app)
2506    for ext in app.extensions.iter() {
2507        for cmd in ext.commands() {
2508            if cmd.name == cmd_name {
2509                // Execute the handler here while we have immutably borrowed app,
2510                // then use the result after dropping the borrow.
2511                let result = cmd.handler.execute(args);
2512                match result {
2513                    Ok(result) => {
2514                        // Drop the iterator borrow before mutating app
2515                        drop((ext, cmd));
2516                        handle_command_result(app, result);
2517                        return;
2518                    }
2519                    Err(e) => {
2520                        drop((ext, cmd));
2521                        chat_info(app, format!("Error executing /{}: {}", cmd_name, e));
2522                        return;
2523                    }
2524                }
2525            }
2526        }
2527    }
2528
2529    // Unknown command
2530    let available: Vec<&str> = app.commands.iter().map(|(n, _)| n.as_str()).collect();
2531    app.status_text = Some(format!(
2532        "Unknown command: /{}. Available: {}",
2533        cmd_name,
2534        available.join(", ")
2535    ));
2536}
2537
2538/// Handle a CommandResult from a slash command.
2539/// Simple results are applied immediately; overlay-requiring ones
2540/// are stored in `pending_command_result` for the main loop.
2541fn handle_command_result(app: &mut App, result: CommandResult) {
2542    match result {
2543        CommandResult::Info(msg) => {
2544            chat_info(app, msg.clone());
2545        }
2546        CommandResult::Quit => {
2547            app.should_quit = true;
2548        }
2549        CommandResult::ModelChanged(model) => {
2550            app.model = model.clone();
2551            app.current_provider = app
2552                .registry
2553                .provider_for_model(&model, Some(&app.current_provider))
2554                .unwrap_or_default();
2555            app.record_model_change(&model);
2556            app.status_text = Some(format!("Model: {}", model));
2557        }
2558        CommandResult::ShowHelp => {
2559            // Needs TUI overlay - defer
2560            app.pending_command_result = Some(result);
2561        }
2562        CommandResult::Reloaded => {
2563            app.refresh_registry();
2564            // Reload settings from disk (pi-compatible)
2565            if let Err(e) = app.settings.reload(&app.cwd) {
2566                app.status_text = Some(format!("Failed to reload settings: {}", e));
2567            } else {
2568                // Apply reloaded settings to runtime state
2569                if let Some(level) = app.settings.default_thinking_level.clone() {
2570                    app.thinking_level = Some(level.clone());
2571                    if let Some(ref mut s) = app.session {
2572                        s.on_thinking_level_change(&level);
2573                    }
2574                    if let Some(ref s) = app.session {
2575                        app.footer.borrow_mut().refresh_from_session(s.session());
2576                    }
2577                    // yoagent hardcodes ThinkingLevel::High
2578                }
2579                app.hide_thinking = app.settings.hide_thinking.unwrap_or(true);
2580                app.propagate_hide_thinking();
2581                app.editor.borrow_mut().update_border_color(
2582                    app.thinking_level.as_deref(),
2583                    &app.theme as &dyn crate::tui::Theme,
2584                );
2585                chat_info(
2586                    app,
2587                    "Settings, extensions, and keybindings reloaded.".to_string(),
2588                );
2589            }
2590        }
2591        CommandResult::NewSession => {
2592            // Matching pi's handleClearCommand:
2593            //   1. Stop loading animation
2594            //   2. Clear status container
2595            //   3. runtimeHost.newSession() -> session.new_session()
2596            //   4. renderCurrentSessionState() -> clear everything
2597            //   5. Add "✓ New session started" with accent color + spacer
2598
2599            // Stop working indicator (matching pi's loadingAnimation.stop())
2600            app.working.stop();
2601
2602            // Clear status section (matching pi's statusContainer.clear())
2603            app.status_text = None;
2604
2605            // Create a new session via AgentSession (new ID, new file, resets tracked state)
2606            if let Some(ref mut agent_session) = app.session {
2607                agent_session.new_session();
2608            }
2609
2610            // Clear everything (matching pi's renderCurrentSessionState)
2611            app.agent = None;
2612            app.clear_session_state();
2613
2614            // Refresh footer cached stats from the now-empty session
2615            if let Some(ref s) = app.session {
2616                app.footer.borrow_mut().refresh_from_session(s.session());
2617            }
2618
2619            // Add "✓ New session started" with accent color, matching pi's
2620            // `new Text(theme.fg("accent", "✓ New session started"), 1, 1)`
2621            let styled = app.theme.fg("accent", "✓ New session started");
2622            chat_add(app, std::boxed::Box::new(Text::new(styled, 1, 1, None)));
2623        }
2624        CommandResult::SessionSwitched { path } => {
2625            let new_session = crate::agent::AgentSession::open(&path, None, Some(&app.cwd));
2626            app.switch_to_session(new_session);
2627            app.status_text = Some(format!("Switched to session: {}", path.display()));
2628        }
2629        CommandResult::SessionInfo {
2630            session_id,
2631            file_path,
2632            name,
2633            message_count,
2634            user_messages,
2635            assistant_messages,
2636            tool_calls,
2637            tool_results,
2638            total_tokens,
2639            input_tokens,
2640            output_tokens,
2641            cache_read_tokens,
2642            cache_write_tokens,
2643            cost,
2644        } => {
2645            let name_display = name
2646                .or_else(|| {
2647                    app.session
2648                        .as_ref()
2649                        .and_then(|s| s.session().session_name())
2650                })
2651                .unwrap_or_else(|| "unnamed".to_string());
2652            let file_display = file_path
2653                .as_ref()
2654                .map(|p| p.display().to_string())
2655                .unwrap_or_else(|| "in-memory".to_string());
2656            let sid = if session_id.is_empty() {
2657                app.session
2658                    .as_ref()
2659                    .map(|s| s.session().session_id())
2660                    .unwrap_or_default()
2661            } else {
2662                session_id
2663            };
2664
2665            let total_messages = message_count;
2666
2667            // Build info display matching pi's handleSessionCommand
2668            let mut info = format!(
2669                "Session Info\n\n\
2670                 Name: {name_display}\n\
2671                 File: {file_display}\n\
2672                 ID: {sid}\n\
2673                 \n\
2674                 Messages\n\
2675                 User: {user_messages}\n\
2676                 Assistant: {assistant_messages}\n\
2677                 Tool Calls: {tool_calls}\n\
2678                 Tool Results: {tool_results}\n\
2679                 Total: {total_messages}\n\
2680                 \n\
2681                 Tokens\n\
2682                 Input: {}\n\
2683                 Output: {}",
2684                format_number(input_tokens),
2685                format_number(output_tokens),
2686            );
2687            if cache_read_tokens > 0 {
2688                info += &format!("\nCache Read: {}", format_number(cache_read_tokens));
2689            }
2690            if cache_write_tokens > 0 {
2691                info += &format!("\nCache Write: {}", format_number(cache_write_tokens));
2692            }
2693            info += &format!("\nTotal: {}", format_number(total_tokens));
2694
2695            if cost > 0.0 {
2696                info += &format!("\n\nCost\nTotal: {:.4}", cost);
2697            }
2698
2699            // Parent session (fork chain)
2700            if let Some(ref asession) = app.session
2701                && let Some(file_path) = asession.session().session_file().as_ref()
2702                && let Some(h) = crate::agent::session::read_session_header(file_path)
2703                && let Some(ref parent) = h.parent_session
2704            {
2705                info += &format!("\n\nParent: {}", parent);
2706            }
2707
2708            chat_info(app, info.clone());
2709        }
2710        CommandResult::OpenSessionSelector => {
2711            // Load and display available sessions
2712            use crate::agent::SessionRepo;
2713            let repo = crate::agent::DefaultSessionRepo::new();
2714            let sessions = repo.list_all(None);
2715
2716            if sessions.is_empty() {
2717                let msg = "No sessions found.".to_string();
2718                chat_info(app, msg.clone());
2719            } else {
2720                let mut info = format!("Available Sessions ({} total)\n\n", sessions.len());
2721                for (i, s) in sessions.iter().take(20).enumerate() {
2722                    let name = s.name.as_deref().unwrap_or("unnamed");
2723                    let cwd_short = s.cwd.rsplit('/').next().unwrap_or(&s.cwd);
2724                    info += &format!(
2725                        "{}. {}  [{}]  {} msgs\n   {}\n\n",
2726                        i + 1,
2727                        name,
2728                        fmt_time_short(&s.created),
2729                        s.message_count,
2730                        cwd_short,
2731                    );
2732                }
2733                if sessions.len() > 20 {
2734                    info += &format!("... and {} more sessions\n", sessions.len() - 20);
2735                }
2736                info += "Use /resume to open the interactive picker";
2737
2738                chat_info(app, info.clone());
2739            }
2740        }
2741        CommandResult::SessionNamed { name } => {
2742            // Persist name in session
2743            if let Some(ref mut s) = app.session {
2744                s.session_mut().append_session_info(&name);
2745            }
2746
2747            // Check if name was normalized (pi-compatible normalization warning)
2748            let stored_name = app
2749                .session
2750                .as_ref()
2751                .and_then(|s| s.session().session_name());
2752            if let Some(ref stored) = stored_name
2753                && stored != &name
2754            {
2755                chat_info(
2756                    app,
2757                    format!("Session name normalized from {:?} to {:?}", name, stored),
2758                );
2759            }
2760
2761            chat_info(
2762                app,
2763                format!(
2764                    "Session name set: {}",
2765                    stored_name.as_deref().unwrap_or(&name)
2766                ),
2767            );
2768
2769            app.status_text = Some(format!(
2770                "Session name set: {}",
2771                stored_name.as_deref().unwrap_or(&name)
2772            ));
2773
2774            // Update session info and footer (refresh_from_session picks up the new name)
2775            app.update_session_info();
2776            if let Some(ref s) = app.session {
2777                app.footer.borrow_mut().refresh_from_session(s.session());
2778            }
2779        }
2780        CommandResult::OpenModelSelector => {
2781            // Needs TUI overlay - defer
2782            app.pending_command_result = Some(result);
2783        }
2784        CommandResult::OpenSettings => {
2785            // Needs TUI overlay - defer
2786            app.pending_command_result = Some(result);
2787        }
2788        CommandResult::ScopedModels => {
2789            // Needs TUI overlay - defer
2790            app.pending_command_result = Some(result);
2791        }
2792        CommandResult::ExportSession { path } => {
2793            let msg = if let Some(p) = path {
2794                format!("Export session to {} - not yet implemented.", p)
2795            } else {
2796                "Export session - not yet implemented (defaults to HTML).".to_string()
2797            };
2798            chat_info(app, msg.clone());
2799        }
2800        CommandResult::ImportSession { path } => {
2801            let msg = format!("Import session from {} - not yet implemented.", path);
2802            chat_info(app, msg.clone());
2803        }
2804        CommandResult::ShareSession => {
2805            let msg = "Share session - not yet implemented.".to_string();
2806            chat_info(app, msg.clone());
2807        }
2808        CommandResult::CopyLastMessage => {
2809            // Get last assistant message text (pi-compatible)
2810            let text = app.session.as_ref().and_then(|s| {
2811                let entries = s.session().get_entries();
2812                entries.iter().rev().find_map(|entry| {
2813                    if let SessionEntry::Message(m) = entry
2814                        && matches!(
2815                                &m.message,
2816                                yoagent::types::AgentMessage::Llm(
2817                                    yoagent::types::Message::Assistant {
2818                                        stop_reason, ..
2819                                    },
2820                                ) if *stop_reason != yoagent::types::StopReason::Aborted
2821                                    || !crate::agent::types::message_text(&m.message)
2822                                        .trim()
2823                                        .is_empty()
2824                        )
2825                    {
2826                        let text = crate::agent::types::message_text(&m.message);
2827                        let trimmed = text.trim();
2828                        if !trimmed.is_empty() {
2829                            return Some(trimmed.to_string());
2830                        }
2831                    }
2832                    None
2833                })
2834            });
2835
2836            let text = match text {
2837                Some(t) => t,
2838                None => {
2839                    chat_info(app, "No agent messages to copy yet.");
2840                    return;
2841                }
2842            };
2843
2844            // Pi-compatible clipboard copy (includes OSC 52 fallback)
2845            copy_to_clipboard(&text);
2846            chat_info(app, "Copied last agent message to clipboard");
2847        }
2848        CommandResult::ShowChangelog => {
2849            let msg = "Changelog - not yet implemented.".to_string();
2850            chat_info(app, msg.clone());
2851        }
2852        CommandResult::ForkSession { message_id } => {
2853            // Clone the session info before modifying app.session
2854            let source_path = app
2855                .session
2856                .as_ref()
2857                .and_then(|s| s.session().session_file());
2858            let session_dir = app.session.as_ref().map(|s| s.session_dir().to_path_buf());
2859            let cwd = app.cwd.clone();
2860
2861            match (source_path, session_dir) {
2862                (Some(ref source), Some(ref target_dir)) => {
2863                    match crate::agent::session::fork_session(
2864                        source,
2865                        target_dir,
2866                        message_id.as_deref(),
2867                        None,
2868                    ) {
2869                        Ok(new_id) => {
2870                            // Find the new session file
2871                            let dir_entries = std::fs::read_dir(target_dir).ok();
2872                            let new_path = dir_entries.and_then(|entries| {
2873                                entries
2874                                    .flatten()
2875                                    .find(|e| {
2876                                        let filename = e.file_name();
2877                                        filename.to_string_lossy().contains(&new_id)
2878                                    })
2879                                    .map(|e| e.path())
2880                            });
2881
2882                            match new_path {
2883                                Some(ref path) => {
2884                                    // Open the new session and replace the current one
2885                                    let new_session =
2886                                        crate::agent::AgentSession::open(path, None, Some(&cwd));
2887                                    app.switch_to_session(new_session);
2888
2889                                    let styled = app.theme.fg(
2890                                        "accent",
2891                                        &format!("✓ Forked session: {}", path.display()),
2892                                    );
2893                                    chat_add(
2894                                        app,
2895                                        std::boxed::Box::new(Text::new(styled, 1, 1, None)),
2896                                    );
2897                                }
2898                                None => {
2899                                    let msg =
2900                                        format!("Fork created but new file not found: {}", new_id);
2901                                    chat_info(app, msg);
2902                                }
2903                            }
2904                        }
2905                        Err(e) => {
2906                            let msg = format!("Fork failed: {}", e);
2907                            chat_info(app, msg.clone());
2908                        }
2909                    }
2910                }
2911                _ => {
2912                    let msg = "No active session to fork".to_string();
2913                    chat_info(app, msg.clone());
2914                }
2915            }
2916        }
2917        CommandResult::CloneSession => {
2918            let msg = "Clone session - not yet implemented.".to_string();
2919            chat_info(app, msg.clone());
2920        }
2921        CommandResult::SessionTree => {
2922            let msg = "Session tree - not yet implemented.".to_string();
2923            chat_info(app, msg.clone());
2924        }
2925        CommandResult::TrustDecision { decision } => {
2926            let msg = format!("Trust decision '{}' saved.", decision);
2927            chat_info(app, msg.clone());
2928        }
2929        CommandResult::Login {
2930            ref provider,
2931            ref api_key,
2932        } => {
2933            if let (Some(provider), Some(key)) = (provider, api_key) {
2934                handle_login(app, provider, Some(key));
2935            } else {
2936                // Needs prompt — defer
2937                app.pending_command_result = Some(result);
2938            }
2939        }
2940        CommandResult::Logout { ref provider } => {
2941            if let Some(p) = provider {
2942                handle_logout(app, Some(p));
2943            } else {
2944                // Needs provider selector — defer
2945                app.pending_command_result = Some(result);
2946            }
2947        }
2948        CommandResult::CompactSession(custom_instructions) => {
2949            // If streaming, interrupt first
2950            if app.is_streaming {
2951                interrupt_streaming(app);
2952            }
2953            app.pending_compact = Some(custom_instructions);
2954        }
2955    }
2956}
2957
2958/// Look up a tool renderer by name from extensions (bundled in ToolDefinition.renderer).
2959fn find_tool_renderer(
2960    extensions: &[Box<dyn crate::agent::extension::Extension>],
2961    name: &str,
2962) -> Option<Arc<dyn ToolRenderer>> {
2963    for ext in extensions {
2964        for tool in ext.tools() {
2965            if tool.name() == name {
2966                return tool.renderer;
2967            }
2968        }
2969    }
2970    None
2971}
2972
2973/// Handle ! and !! bang commands.
2974/// Renders via ToolExecComponent with the bash renderer (same visual treatment
2975/// as LLM-invoked bash tool calls, eliminating the separate BashExecution split).
2976fn handle_bang_command(app: &mut App, command: String) {
2977    let cwd = app.cwd.clone();
2978    let tx = app.event_tx.clone();
2979    use yoagent::types::{AgentEvent as YoEvent, Content as YoContent, ToolResult as YoResult};
2980
2981    let renderer = find_tool_renderer(&app.extensions, "bash");
2982    let mut tool = crate::agent::ui::components::ToolExecComponent::new(
2983        "bash",
2984        renderer,
2985        serde_json::json!({"command": command}),
2986        app.cwd.to_string_lossy().to_string(),
2987        "__bang__".to_string(),
2988    );
2989    tool.set_started_at(std::time::Instant::now());
2990    let (invalidate_tx, invalidate_rx) =
2991        crate::agent::ui::components::ToolExecComponent::make_invalidation_channel();
2992    app.invalidate_rxs.push(invalidate_rx);
2993    tool.set_invalidate_tx(invalidate_tx);
2994    tool.set_expanded(app.tools_expanded);
2995    let tool = Rc::new(RefCell::new(tool));
2996    app.pending_tools
2997        .insert("__bang__".to_string(), Rc::downgrade(&tool));
2998    chat_add(
2999        app,
3000        std::boxed::Box::new(crate::agent::ui::components::RcToolExec(tool)),
3001    );
3002    app.is_streaming = true;
3003    app.working.start();
3004    app.footer.borrow_mut().set_streaming(true);
3005    app.pending_tool_executions += 1;
3006
3007    let handle = tokio::spawn(async move {
3008        struct Guard<'a> {
3009            tx: &'a mpsc::UnboundedSender<yoagent::types::AgentEvent>,
3010            sent: bool,
3011        }
3012        impl Drop for Guard<'_> {
3013            fn drop(&mut self) {
3014                if !self.sent {
3015                    let _ = self.tx.send(YoEvent::AgentEnd { messages: vec![] });
3016                }
3017            }
3018        }
3019        let mut guard = Guard {
3020            tx: &tx,
3021            sent: false,
3022        };
3023
3024        let send_progress = |text: &str| {
3025            let _ = tx.send(YoEvent::ProgressMessage {
3026                tool_call_id: "__bang__".to_string(),
3027                tool_name: "bash".into(),
3028                text: text.to_string(),
3029            });
3030        };
3031
3032        let mut child = match tokio::process::Command::new("sh")
3033            .arg("-c")
3034            .arg(&command)
3035            .current_dir(&cwd)
3036            .stdout(std::process::Stdio::piped())
3037            .stderr(std::process::Stdio::piped())
3038            .spawn()
3039        {
3040            Ok(c) => c,
3041            Err(e) => {
3042                let _ = tx.send(YoEvent::ToolExecutionEnd {
3043                    tool_call_id: "__bang__".to_string(),
3044                    tool_name: "bash".into(),
3045                    result: YoResult {
3046                        content: vec![YoContent::Text {
3047                            text: format!("Failed to execute: {:#}", e),
3048                        }],
3049                        details: serde_json::Value::Null,
3050                    },
3051                    is_error: true,
3052                });
3053                guard.sent = true;
3054                let _ = tx.send(YoEvent::AgentEnd { messages: vec![] });
3055                return;
3056            }
3057        };
3058
3059        let mut all_output = String::new();
3060        // Stream stdout and stderr concurrently using tokio async reads
3061        use tokio::io::AsyncReadExt;
3062        let mut stdio = child.stdout.take().unwrap();
3063        let mut stderr = child.stderr.take().unwrap();
3064        let mut buf1 = [0u8; 4096];
3065        let mut buf2 = [0u8; 4096];
3066        let mut stdout_done = false;
3067        let mut stderr_done = false;
3068
3069        loop {
3070            tokio::select! {
3071                result = stdio.read(&mut buf1), if !stdout_done => {
3072                    match result {
3073                        Ok(0) => stdout_done = true,
3074                        Ok(n) => {
3075                            if let Ok(text) = std::str::from_utf8(&buf1[..n]) {
3076                                all_output.push_str(text);
3077                                send_progress(text);
3078                            }
3079                        }
3080                        Err(_) => stdout_done = true,
3081                    }
3082                }
3083                result = stderr.read(&mut buf2), if !stderr_done => {
3084                    match result {
3085                        Ok(0) => stderr_done = true,
3086                        Ok(n) => {
3087                            if let Ok(text) = std::str::from_utf8(&buf2[..n]) {
3088                                all_output.push_str(text);
3089                                send_progress(text);
3090                            }
3091                        }
3092                        Err(_) => stderr_done = true,
3093                    }
3094                }
3095            }
3096            if stdout_done && stderr_done {
3097                break;
3098            }
3099        }
3100
3101        // Wait for process to finish
3102        let status = child.wait().await;
3103        let is_error = match &status {
3104            Ok(s) => !s.success(),
3105            Err(_) => true,
3106        };
3107        let result = if all_output.trim().is_empty() {
3108            "(no output)".to_string()
3109        } else {
3110            all_output.trim().to_string()
3111        };
3112
3113        let _ = tx.send(YoEvent::ToolExecutionEnd {
3114            tool_call_id: "__bang__".to_string(),
3115            tool_name: "bash".into(),
3116            result: YoResult {
3117                content: vec![YoContent::Text { text: result }],
3118                details: serde_json::Value::Null,
3119            },
3120            is_error,
3121        });
3122        guard.sent = true;
3123        let _ = tx.send(YoEvent::AgentEnd { messages: vec![] });
3124    });
3125    app.bash_abort_handle = Some(handle.abort_handle());
3126}
3127
3128/// Rebuild the chat container from a slice of AgentMessages (pi's renderSessionContext).
3129/// Clears the container and re-adds all message components with spacers between them.
3130/// Adjacent tool calls and tool results are paired into single ToolExecComponent.
3131pub fn rebuild_chat_from_messages(
3132    chat: &mut crate::tui::Container,
3133    messages: &[yoagent::types::AgentMessage],
3134    cwd: &str,
3135    hide_thinking: bool,
3136    _collapse_tool_output: bool,
3137    extensions: &[Box<dyn crate::agent::extension::Extension>],
3138) {
3139    chat.clear();
3140    use std::collections::HashMap;
3141    let mut pending_tool_components: HashMap<
3142        String,
3143        Rc<RefCell<crate::agent::ui::components::ToolExecComponent>>,
3144    > = HashMap::new();
3145
3146    for msg in messages {
3147        if crate::agent::types::message_is_user(msg) {
3148            let text = crate::agent::types::message_text(msg);
3149            if text.is_empty() {
3150                continue;
3151            }
3152            if !chat.children().is_empty() {
3153                chat.add_child(std::boxed::Box::new(Spacer::new(1)));
3154            }
3155            chat.add_child(std::boxed::Box::new(
3156                crate::agent::ui::components::UserMessageComponent::new(text),
3157            ));
3158        } else if crate::agent::types::message_is_assistant(msg) {
3159            let text = crate::agent::types::message_text(msg);
3160            if let yoagent::types::AgentMessage::Llm(yoagent::types::Message::Assistant {
3161                content,
3162                ..
3163            }) = msg
3164            {
3165                let tcs = crate::agent::types::content_tool_calls(content);
3166                if !tcs.is_empty() {
3167                    // Assistant with tool calls — render text first
3168                    if !text.trim().is_empty() {
3169                        add_assistant_message(chat, &text, hide_thinking);
3170                    }
3171                    // Create ToolExecComponent for each tool call
3172                    for (id, name, args) in &tcs {
3173                        let renderer = find_tool_renderer(extensions, name);
3174                        let tool = crate::agent::ui::components::ToolExecComponent::new(
3175                            name,
3176                            renderer,
3177                            args.clone(),
3178                            cwd.to_string(),
3179                            id.clone(),
3180                        );
3181                        let tool = Rc::new(RefCell::new(tool));
3182                        chat.add_child(std::boxed::Box::new(
3183                            crate::agent::ui::components::RcToolExec(tool.clone()),
3184                        ));
3185                        pending_tool_components.insert(id.clone(), tool);
3186                    }
3187                } else if !text.trim().is_empty() {
3188                    // Plain text assistant
3189                    add_assistant_message(chat, &text, hide_thinking);
3190                }
3191            }
3192        } else if crate::agent::types::message_is_tool_result(msg) {
3193            let is_error = crate::agent::types::message_is_error(msg);
3194            let text = crate::agent::types::message_text(msg);
3195            if let Some(tc_id) = crate::agent::types::message_tool_call_id(msg)
3196                && let Some(tool) = pending_tool_components.remove(tc_id)
3197            {
3198                let clean = text
3199                    .strip_prefix("✓ ")
3200                    .or_else(|| text.strip_prefix("✗ "))
3201                    .unwrap_or(&text);
3202                let mut tool = tool.borrow_mut();
3203                tool.set_result_with_details(clean, is_error, None);
3204            }
3205        } else if crate::agent::types::message_is_extension(msg) {
3206            // Extension messages (info, error, system_stop) rendered as info text.
3207            if let Some(text) = crate::agent::types::message_extension_text(msg) {
3208                if !chat.children().is_empty() {
3209                    chat.add_child(std::boxed::Box::new(Spacer::new(1)));
3210                }
3211                chat.add_child(std::boxed::Box::new(InfoMessageComponent::new(text)));
3212            }
3213        }
3214    }
3215}
3216
3217/// Add a Component to chat_container with a spacer before it if chat_container is not empty.
3218/// Mirrors pi's `addMessageToChat()` which adds `new Spacer(1)` before each message
3219/// when `this.chatContainer.children.length > 0`.
3220pub fn chat_add(app: &mut App, component: std::boxed::Box<dyn Component>) {
3221    let mut chat = app.chat_container.borrow_mut();
3222    if !chat.children().is_empty() {
3223        chat.add_child(std::boxed::Box::new(Spacer::new(1)));
3224    }
3225    chat.add_child(component);
3226}
3227
3228/// Convenience shortcut: add an InfoMessageComponent to chat.
3229pub fn chat_info(app: &mut App, msg: impl Into<String>) {
3230    chat_add(
3231        app,
3232        std::boxed::Box::new(InfoMessageComponent::new(msg.into())),
3233    );
3234}
3235
3236/// Add an AssistantMessageComponent with a preceding spacer.
3237fn add_assistant_message(chat: &mut crate::tui::Container, text: &str, hide_thinking: bool) {
3238    if !chat.children().is_empty() {
3239        chat.add_child(std::boxed::Box::new(Spacer::new(1)));
3240    }
3241    let mut asst = crate::agent::ui::components::AssistantMessageComponent::new(text);
3242    if hide_thinking {
3243        asst.set_hide_thinking(true);
3244    }
3245    chat.add_child(std::boxed::Box::new(asst));
3246}
3247
3248/// Show a status message in the chat (pi-style `showStatus`).
3249///
3250/// If the last two children of `chat_container` are from a previous status
3251/// (spacer + InfoMessageComponent), they are replaced in-place rather than
3252/// appending new entries. This prevents multiple consecutive status messages
3253/// from accumulating at the end of the chat session.
3254fn show_status(app: &mut App, message: String) {
3255    let mut chat = app.chat_container.borrow_mut();
3256    // Check if previous status children are still the last in the container
3257    if let Some(prev_len) = app.last_status_len
3258        && chat.len() == prev_len
3259        && prev_len >= 2
3260    {
3261        chat.pop_child(); // info message
3262        chat.pop_child(); // spacer
3263    }
3264    app.last_status_len = None;
3265    drop(chat);
3266
3267    // Add the new status
3268    let mut chat = app.chat_container.borrow_mut();
3269    if !chat.children().is_empty() {
3270        chat.add_child(std::boxed::Box::new(Spacer::new(1)));
3271    }
3272    chat.add_child(std::boxed::Box::new(InfoMessageComponent::new(message)));
3273    app.last_status_len = Some(chat.len());
3274}
3275
3276/// Concatenate all Text content from a slice of Content values.
3277fn extract_text_content(content: &[yoagent::types::Content]) -> String {
3278    content
3279        .iter()
3280        .filter_map(|c| {
3281            if let yoagent::types::Content::Text { text } = c {
3282                Some(text.clone())
3283            } else {
3284                None
3285            }
3286        })
3287        .collect::<Vec<_>>()
3288        .join("")
3289}
3290
3291/// Try to copy text to the system clipboard using platform-specific tools.
3292/// Returns true if successful, false if no tool was available.
3293/// Falls back to OSC 52 escape sequence for remote sessions.
3294/// Mirrors pi's clipboard strategy exactly.
3295fn copy_to_clipboard(text: &str) -> bool {
3296    use std::io::Write;
3297    let mut copied = false;
3298
3299    // macOS
3300    if !copied
3301        && std::process::Command::new("pbcopy")
3302            .stdin(std::process::Stdio::piped())
3303            .stdout(std::process::Stdio::null())
3304            .stderr(std::process::Stdio::null())
3305            .spawn()
3306            .ok()
3307            .and_then(|mut child| {
3308                let _ = child.stdin.take().map(|mut stdin| {
3309                    let _ = stdin.write_all(text.as_bytes());
3310                });
3311                child.wait().ok()
3312            })
3313            .is_some_and(|s| s.success())
3314    {
3315        copied = true;
3316    }
3317
3318    // Windows
3319    if !copied
3320        && std::process::Command::new("clip")
3321            .stdin(std::process::Stdio::piped())
3322            .stdout(std::process::Stdio::null())
3323            .stderr(std::process::Stdio::null())
3324            .spawn()
3325            .ok()
3326            .and_then(|mut child| {
3327                let _ = child.stdin.take().map(|mut stdin| {
3328                    let _ = stdin.write_all(text.as_bytes());
3329                });
3330                child.wait().ok()
3331            })
3332            .is_some_and(|s| s.success())
3333    {
3334        copied = true;
3335    }
3336
3337    // Linux / Termux
3338    if !copied
3339        && std::env::var("TERMUX_VERSION").is_ok()
3340        && let Ok(mut child) = std::process::Command::new("termux-clipboard-set")
3341            .stdin(std::process::Stdio::piped())
3342            .stdout(std::process::Stdio::null())
3343            .stderr(std::process::Stdio::null())
3344            .spawn()
3345    {
3346        let _ = child.stdin.take().map(|mut stdin| {
3347            let _ = stdin.write_all(text.as_bytes());
3348        });
3349        copied = child.wait().ok().is_some_and(|s| s.success());
3350    }
3351
3352    // Wayland: spawn wl-copy without waiting (it daemonizes, pi-compatible)
3353    if !copied
3354        && std::env::var("WAYLAND_DISPLAY").is_ok()
3355        && std::process::Command::new("which")
3356            .arg("wl-copy")
3357            .stdout(std::process::Stdio::null())
3358            .stderr(std::process::Stdio::null())
3359            .status()
3360            .ok()
3361            .is_some_and(|s| s.success())
3362        && let Ok(mut child) = std::process::Command::new("wl-copy")
3363            .stdin(std::process::Stdio::piped())
3364            .stdout(std::process::Stdio::null())
3365            .stderr(std::process::Stdio::null())
3366            .spawn()
3367    {
3368        let _ = child.stdin.take().map(|mut stdin| {
3369            let _ = stdin.write_all(text.as_bytes());
3370        });
3371        // Don't wait — wl-copy daemonizes (pi-compatible)
3372        copied = true;
3373    }
3374
3375    // X11: try xclip, then xsel
3376    if !copied
3377        && std::process::Command::new("xclip")
3378            .arg("-selection")
3379            .arg("clipboard")
3380            .arg("-i")
3381            .stdin(std::process::Stdio::piped())
3382            .stdout(std::process::Stdio::null())
3383            .stderr(std::process::Stdio::null())
3384            .spawn()
3385            .ok()
3386            .and_then(|mut child| {
3387                let _ = child.stdin.take().map(|mut stdin| {
3388                    let _ = stdin.write_all(text.as_bytes());
3389                });
3390                child.wait().ok()
3391            })
3392            .is_some_and(|s| s.success())
3393    {
3394        copied = true;
3395    }
3396
3397    if !copied
3398        && std::process::Command::new("xsel")
3399            .arg("--clipboard")
3400            .arg("--input")
3401            .stdin(std::process::Stdio::piped())
3402            .stdout(std::process::Stdio::null())
3403            .stderr(std::process::Stdio::null())
3404            .spawn()
3405            .ok()
3406            .and_then(|mut child| {
3407                let _ = child.stdin.take().map(|mut stdin| {
3408                    let _ = stdin.write_all(text.as_bytes());
3409                });
3410                child.wait().ok()
3411            })
3412            .is_some_and(|s| s.success())
3413    {
3414        copied = true;
3415    }
3416
3417    // OSC 52 fallback: emit for remote sessions or when nothing copied
3418    let remote = std::env::var("SSH_CONNECTION").is_ok()
3419        || std::env::var("SSH_CLIENT").is_ok()
3420        || std::env::var("MOSH_CONNECTION").is_ok();
3421
3422    if remote || !copied {
3423        use base64::Engine as _;
3424        let encoded = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
3425        // Pi-compatible: skip OSC 52 for very large payloads (>100KB encoded)
3426        if encoded.len() <= 100_000 {
3427            let _ = writeln!(std::io::stdout(), "\x1b]52;c;{}\x07", encoded);
3428            let _ = std::io::stdout().flush();
3429            copied = true;
3430        }
3431    }
3432
3433    copied
3434}
3435
3436/// Handle agent events from the channel.
3437///
3438/// Delegates persistence to `AgentSession::on_agent_event()` (single source of truth)
3439/// and only handles display/UI logic here. This mirrors pi's single _handleAgentEvent
3440/// that all modes share — the mode-agnostic persistence lives on AgentSession, and each
3441/// mode adds display on top.
3442fn handle_agent_event(app: &mut App, event: yoagent::types::AgentEvent) {
3443    // ── Persistence: delegate to the shared handler (single source of truth) ──
3444    // Handle with &event before the display match consumes it.
3445    {
3446        let ev = &event;
3447        if let E::MessageEnd { message } = ev {
3448            if crate::agent::types::message_is_user(message)
3449                && let Some(ref mut s) = app.session
3450            {
3451                s.reset_overflow_recovery();
3452            }
3453            if crate::agent::types::message_error(message).is_none()
3454                && !crate::agent::types::message_is_system_stop(message)
3455                && let Some(ref mut s) = app.session
3456            {
3457                s.on_agent_event(ev);
3458            }
3459        }
3460        if let E::ToolExecutionEnd { tool_call_id, .. } = ev
3461            && tool_call_id != "__bang__"
3462            && let Some(ref mut s) = app.session
3463        {
3464            s.on_agent_event(ev);
3465        }
3466        if let E::AgentEnd { .. } = ev
3467            && let Some(ref mut s) = app.session
3468        {
3469            s.on_agent_event(ev);
3470        }
3471    }
3472
3473    // ── Display logic (consumes owned event) ──
3474    use yoagent::types::AgentEvent as E;
3475    match event {
3476        E::AgentStart => {
3477            app.is_streaming = true;
3478            app.working.start();
3479            app.refresh_git_branch();
3480        }
3481        E::TurnStart => {}
3482        E::MessageStart { message } => {
3483            // Add user messages to chat when the agent loop processes them.
3484            // Covers both the initial prompt (non-streaming) and
3485            // steered/follow-up messages queued during streaming.
3486            if crate::agent::types::message_is_user(&message) {
3487                let text = crate::agent::types::message_text(&message);
3488                if !text.is_empty() {
3489                    chat_add(
3490                        app,
3491                        std::boxed::Box::new(
3492                            crate::agent::ui::components::UserMessageComponent::new(&text),
3493                        ),
3494                    );
3495                }
3496            }
3497        }
3498        E::MessageUpdate { delta, .. } => {
3499            use yoagent::types::StreamDelta;
3500            match delta {
3501                StreamDelta::Text { delta } => {
3502                    if let Some(weak) = app.streaming_component.as_ref().and_then(|w| w.upgrade()) {
3503                        weak.borrow_mut().append_text(&delta);
3504                    } else {
3505                        use crate::tui::components::rc_ref_cell_component::RcRefCellComponent;
3506                        let comp = Rc::new(RefCell::new(
3507                            crate::agent::ui::components::AssistantMessageComponent::new(&delta),
3508                        ));
3509                        if app.hide_thinking {
3510                            comp.borrow_mut().set_hide_thinking(true);
3511                        }
3512                        app.streaming_component = Some(Rc::downgrade(&comp));
3513                        app.chat_container
3514                            .borrow_mut()
3515                            .add_child(std::boxed::Box::new(RcRefCellComponent(comp)));
3516                    }
3517                }
3518                StreamDelta::Thinking { delta } => {
3519                    if let Some(weak) = app.streaming_component.as_ref().and_then(|w| w.upgrade()) {
3520                        weak.borrow_mut()
3521                            .add_thinking(&delta, app.thinking_level.clone());
3522                    } else {
3523                        use crate::tui::components::rc_ref_cell_component::RcRefCellComponent;
3524                        let mut comp =
3525                            crate::agent::ui::components::AssistantMessageComponent::new("");
3526                        comp.add_thinking(&delta, app.thinking_level.clone());
3527                        if app.hide_thinking {
3528                            comp.set_hide_thinking(true);
3529                        }
3530                        let comp = Rc::new(RefCell::new(comp));
3531                        app.streaming_component = Some(Rc::downgrade(&comp));
3532                        app.chat_container
3533                            .borrow_mut()
3534                            .add_child(std::boxed::Box::new(RcRefCellComponent(comp)));
3535                    }
3536                }
3537                StreamDelta::ToolCallDelta { .. } => {}
3538            }
3539        }
3540        E::ToolExecutionStart {
3541            tool_call_id,
3542            tool_name,
3543            args,
3544        } => {
3545            app.pending_tool_executions += 1;
3546            app.streaming_component = None;
3547            let name = tool_name;
3548            let renderer = find_tool_renderer(&app.extensions, &name);
3549            let started_at = std::time::Instant::now();
3550            let (invalidate_tx, invalidate_rx) =
3551                crate::agent::ui::components::ToolExecComponent::make_invalidation_channel();
3552            app.invalidate_rxs.push(invalidate_rx);
3553            let comp: Rc<RefCell<_>> = {
3554                let mut tool = crate::agent::ui::components::ToolExecComponent::new(
3555                    &name,
3556                    renderer,
3557                    args.clone(),
3558                    app.cwd.to_string_lossy().to_string(),
3559                    tool_call_id.clone(),
3560                );
3561                tool.set_started_at(std::time::Instant::now());
3562                tool.set_invalidate_tx(invalidate_tx);
3563                Rc::new(RefCell::new(tool))
3564            };
3565            comp.borrow_mut().set_expanded(app.tools_expanded);
3566            app.pending_tools
3567                .insert(tool_call_id.clone(), Rc::downgrade(&comp));
3568            app.tool_call_start_times
3569                .insert(tool_call_id.clone(), started_at);
3570            chat_add(
3571                app,
3572                std::boxed::Box::new(crate::agent::ui::components::RcToolExec(comp)),
3573            );
3574        }
3575        E::ToolExecutionUpdate {
3576            tool_call_id,
3577            partial_result,
3578            ..
3579        } => {
3580            // Forward partial results to the pending tool component (live streaming).
3581            let partial_text = extract_text_content(&partial_result.content);
3582            if !partial_text.is_empty()
3583                && let Some(weak) = app.pending_tools.get(&tool_call_id)
3584                && let Some(comp) = weak.upgrade()
3585            {
3586                comp.borrow_mut().append_output(&partial_text);
3587            }
3588        }
3589        E::ToolExecutionEnd {
3590            tool_call_id,
3591            tool_name: _,
3592            result,
3593            is_error,
3594        } => {
3595            app.pending_tool_executions = app.pending_tool_executions.saturating_sub(1);
3596            let content = extract_text_content(&result.content);
3597            if let Some(weak) = app.pending_tools.get(&tool_call_id)
3598                && let Some(comp) = weak.upgrade()
3599            {
3600                comp.borrow_mut()
3601                    .set_result_with_details(&content, is_error, Some(result.details));
3602                app.tool_call_start_times.remove(&tool_call_id);
3603            }
3604        }
3605        E::ProgressMessage {
3606            text, tool_name, ..
3607        } => {
3608            // Bang (") command progress feeds into pending_tools["__bang__"]
3609            if let Some(weak) = app.pending_tools.get("__bang__")
3610                && let Some(comp) = weak.upgrade()
3611            {
3612                comp.borrow_mut().append_output(&text);
3613            } else if tool_name.is_empty() {
3614                // General progress message (not tool-specific) — show as status
3615                app.status_text = Some(text.trim().to_string());
3616            }
3617        }
3618        E::TurnEnd { message, .. } => {
3619            app.streaming_component = None;
3620            // Surface provider errors carried by the turn's final message.
3621            if let Some(err) = crate::agent::types::message_error(&message) {
3622                chat_info(app, format!("Provider error: {}", err));
3623            }
3624        }
3625        E::AgentEnd { messages } => {
3626            app.streaming_component = None;
3627            app.is_streaming = false;
3628            app.working.stop();
3629            app.footer.borrow_mut().set_streaming(false);
3630            // Refresh footer cached stats from session at turn end (pull-based)
3631            if let Some(ref s) = app.session {
3632                app.footer.borrow_mut().refresh_from_session(s.session());
3633            }
3634            // Pi-compatible: schedule auto-compaction check after agent ends.
3635            // check_auto_compact() is called asynchronously in the main loop.
3636            app.pending_auto_compact = app.auto_compact;
3637            // Detect silent stops / provider errors: surface any assistant message
3638            // that ended without visible output (empty content or provider error).
3639            // Provider errors with error_message set were never forwarded as
3640            // MessageEnd events (the provider returned Err() without streaming),
3641            // so they must be surfaced here.
3642            for msg in messages.iter().rev() {
3643                if let Some(yoagent::types::Message::Assistant {
3644                    content,
3645                    stop_reason,
3646                    error_message,
3647                    ..
3648                }) = msg.as_llm()
3649                    && stop_reason != &yoagent::types::StopReason::ToolUse
3650                {
3651                    if let Some(err) = error_message {
3652                        chat_info(app, format!("Provider error: {}", err));
3653                        break;
3654                    }
3655                    // Check for any visible content: non-empty text or tool calls.
3656                    // Thinking blocks alone don't count as visible output
3657                    // (they may be hidden or just cut-off thoughts).
3658                    let has_visible = content.iter().any(|c| match c {
3659                        yoagent::types::Content::Text { text } => !text.trim().is_empty(),
3660                        yoagent::types::Content::ToolCall { .. } => true,
3661                        _ => false,
3662                    });
3663                    if !has_visible {
3664                        chat_info(
3665                            app,
3666                            "The agent returned an empty response. \
3667                                 This can happen when the provider's context \
3668                                 limit is exceeded or the model declined to \
3669                                 respond. Try sending a new message."
3670                                .to_string(),
3671                        );
3672                        break;
3673                    }
3674                }
3675            }
3676        }
3677        E::MessageEnd { message } => {
3678            // Special cases: persist as extension (excluded from LLM context).
3679            // Normal persistence handled by if-let above before the display match.
3680            if let Some(err) = crate::agent::types::message_error(&message) {
3681                chat_info(app, err.to_string());
3682                let ext = crate::agent::types::extension_message("error", err, true);
3683                if let Some(ref mut s) = app.session {
3684                    s.persist_extension_message(&ext);
3685                }
3686            } else if crate::agent::types::message_is_system_stop(&message) {
3687                let text = crate::agent::types::message_text(&message);
3688                chat_info(app, text.clone());
3689                if let Some(ref mut s) = app.session {
3690                    let ext = crate::agent::types::extension_message("system_stop", text, true);
3691                    s.persist_extension_message(&ext);
3692                }
3693            } else if crate::agent::types::message_is_extension(&message) {
3694                // Extension messages: display in chat (persisted by on_agent_event).
3695                if let Some(text) = crate::agent::types::message_extension_text(&message) {
3696                    chat_info(app, text);
3697                }
3698            }
3699        }
3700        E::InputRejected { reason } => {
3701            let msg = format!("Input rejected: {}", reason);
3702            chat_info(app, msg);
3703        }
3704    }
3705}
3706
3707/// Parse a ! or !! bang command from input.
3708fn parse_bang_command(input: &str) -> Option<(String, bool)> {
3709    if let Some(rest) = input.strip_prefix("!!") {
3710        let cmd = rest.trim();
3711        if cmd.is_empty() {
3712            None
3713        } else {
3714            Some((cmd.to_string(), true))
3715        }
3716    } else if let Some(rest) = input.strip_prefix('!') {
3717        let cmd = rest.trim();
3718        if cmd.is_empty() {
3719            None
3720        } else {
3721            Some((cmd.to_string(), false))
3722        }
3723    } else {
3724        None
3725    }
3726}
3727
3728/// Format a number with locale-style thousands separators (e.g. 1234 -> "1,234").
3729fn format_number(n: u64) -> String {
3730    let s = n.to_string();
3731    let mut result = String::new();
3732    for (i, c) in s.chars().rev().enumerate() {
3733        if i > 0 && i % 3 == 0 {
3734            result.push(',');
3735        }
3736        result.push(c);
3737    }
3738    result.chars().rev().collect()
3739}
3740
3741/// Format a DateTime for short display (YYYY-MM-DD HH:MM).
3742fn fmt_time_short(dt: &chrono::DateTime<chrono::Utc>) -> String {
3743    dt.format("%Y-%m-%d %H:%M").to_string()
3744}
3745
3746// ── Skills utilities (moved inline from skills.rs) ─────────────────
3747
3748fn xml_escape(s: &str) -> String {
3749    s.replace('&', "&amp;")
3750        .replace('<', "&lt;")
3751        .replace('>', "&gt;")
3752        .replace('"', "&quot;")
3753        .replace('\'', "&apos;")
3754}
3755
3756fn strip_frontmatter(content: &str) -> String {
3757    let content = content.trim_start();
3758    if !content.starts_with("---") {
3759        return content.to_string();
3760    }
3761    let remaining = &content[3..];
3762    let end = match remaining.find("---") {
3763        Some(pos) => pos,
3764        None => return content.to_string(),
3765    };
3766    let body_start = 3 + end + 3;
3767    content[body_start..].trim().to_string()
3768}
3769
3770fn read_skill_body(file_path: &std::path::Path) -> Option<String> {
3771    let content = std::fs::read_to_string(file_path).ok()?;
3772    Some(strip_frontmatter(&content))
3773}
3774
3775fn format_skill_invocation(skill: &yoagent::skills::Skill, extra: Option<&str>) -> String {
3776    let body = read_skill_body(&skill.file_path).unwrap_or_default();
3777    let base = skill.base_dir.to_string_lossy();
3778    let block = format!(
3779        r#"<skill name="{}" location="{}">
3780References are relative to {}.
3781
3782{}
3783</skill>"#,
3784        xml_escape(&skill.name),
3785        xml_escape(&skill.file_path.to_string_lossy()),
3786        base,
3787        body
3788    );
3789    match extra {
3790        Some(instr) if !instr.is_empty() => format!("{}\n\n{}", block, instr),
3791        _ => block,
3792    }
3793}
3794
3795fn expand_skill_command(text: &str, skills: &[yoagent::skills::Skill]) -> String {
3796    if !text.starts_with("/skill:") {
3797        return text.to_string();
3798    }
3799    let rest = &text[7..];
3800    let (skill_name, args) = match rest.find(' ') {
3801        Some(pos) => (&rest[..pos], rest[pos + 1..].trim()),
3802        None => (rest, ""),
3803    };
3804    match skills.iter().find(|s| s.name == skill_name) {
3805        Some(s) => format_skill_invocation(s, if args.is_empty() { None } else { Some(args) }),
3806        None => text.to_string(),
3807    }
3808}