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