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;
15
16use crate::agent::ui::chat_editor::{ChatEditor, InputAction};
17use crate::agent::ui::components::EditorComponent;
18use crate::agent::ui::components::FooterComponent;
19use crate::agent::ui::components::InfoMessageComponent;
20use crate::agent::ui::footer::Footer;
21use crate::agent::ui::model_selector::ModelSelector;
22use crate::agent::ui::theme::RabTheme;
23use crate::agent::ui::working::WorkingIndicator;
24use crate::builtin::commands::SessionInfoInternal;
25use crate::tui::Component;
26use crate::tui::TUI;
27use crate::tui::focusable::Focusable;
28
29use crate::agent::ui::theme::ThemeKey;
30use crate::tui::components::Spacer;
31use crate::tui::components::Text;
32use crate::tui::terminal::{self, ProcessTerminal, TerminalTrait};
33use crossterm::event::KeyEvent;
34use tokio::sync::mpsc;
35
36/// Thinking level cycle order (matching pi's thinking level enum).
37/// Thinking level cycle order. Cycles from highest to lowest so the first
38/// press from the default (xhigh) goes to "high" (a step down), not to "off".
39const THINKING_LEVELS: &[&str] = &["xhigh", "high", "medium", "low", "off"];
40
41/// Configuration for the UI app.
42pub struct AppConfig {
43    pub model: String,
44    pub system_prompt: String,
45    pub extensions: Vec<Box<dyn Extension>>,
46    pub cwd: PathBuf,
47    pub thinking_level: Option<String>,
48    pub available_models: Vec<String>,
49    pub hide_thinking: bool,
50    pub collapse_tool_output: bool,
51    pub interactive: bool,
52    pub settings: crate::agent::settings::Settings,
53    /// Context files (AGENTS.md / CLAUDE.md) loaded for the session.
54    pub context_files: Vec<String>,
55
56    /// Skills loaded for the session (used for /skill:name expansion).
57    pub skills: Vec<yoagent::skills::Skill>,
58    /// Whether the current model supports reasoning (for showing thinking level in footer).
59    pub model_supports_reasoning: bool,
60    /// Session info Arc for /session command (shared with CommandsExtension).
61    pub session_info: Option<std::sync::Arc<std::sync::Mutex<Option<SessionInfoInternal>>>>,
62    /// API key for yoagent provider.
63    pub api_key: String,
64}
65
66/// Main application state.
67pub struct App {
68    cwd: PathBuf,
69    model: String,
70    thinking_level: Option<String>,
71    system_prompt: String,
72    theme: RabTheme,
73
74    /// Slash commands from all extensions.
75    commands: Vec<(String, String)>,
76
77    /// Available models for the model selector.
78    available_models: Vec<String>,
79
80    /// Component-based chat area - mirrors pi's `this.chatContainer`.
81    /// Components are added here in handle_agent_event instead of pushing to messages.
82    pub chat_container: std::rc::Rc<std::cell::RefCell<crate::tui::Container>>,
83
84    // ── Section components for the UI layout (written by compose_ui) ──
85    /// Status text section (transient, dim).
86    pub status_section: std::rc::Rc<std::cell::RefCell<crate::tui::components::DynamicLines>>,
87    /// Working indicator section.
88    pub working_section: std::rc::Rc<std::cell::RefCell<crate::tui::components::DynamicLines>>,
89
90    /// The chat editor (shared ownership - App mutates, TUI.root renders).
91    editor: Rc<RefCell<ChatEditor>>,
92
93    /// Agent event channel.
94    event_tx: mpsc::UnboundedSender<yoagent::types::AgentEvent>,
95    event_rx: mpsc::UnboundedReceiver<yoagent::types::AgentEvent>,
96
97    /// Streaming state.
98    is_streaming: bool,
99    /// Pending agent submission (set by sync handle_input, consumed by async main loop).
100    pending_submit: Option<String>,
101    /// Pending manual compaction (carries optional custom instructions).
102    pending_compact: Option<Option<String>>,
103    /// Pending auto-compaction check after AgentEnd (pi-compatible).
104    pending_auto_compact: bool,
105    /// The reused Agent (accumulates messages across turns, supports mid-turn steering).
106    agent: Option<yoagent::agent::Agent>,
107    /// Handle for the forwarding task that relays events from the agent's event
108    /// receiver to the UI channel. The Agent stays in `app.agent` during streaming.
109    forward_handle: Option<tokio::task::JoinHandle<()>>,
110
111    /// Display settings.
112    hide_thinking: bool,
113    collapse_tool_output: bool,
114    /// Global toggle: expand all tool outputs (Ctrl+O). Inverted of collapse_tool_output.
115    tools_expanded: bool,
116
117    /// Chat scroll offset (lines scrolled up from bottom).
118    scroll_offset: usize,
119
120    /// Timestamp of last Ctrl+C for double-press detection (pi-style).
121    last_clear_time: std::time::Instant,
122
123    /// Exit flag.
124    should_quit: bool,
125
126    /// Number of tool executions currently in-flight.
127    /// Incremented on ToolExecutionStart, decremented on ToolExecutionEnd.
128    /// Used to skip the 15s inactivity timeout while tools are running,
129    /// since long-running tools (e.g. bash) may not emit progress events.
130    pending_tool_executions: usize,
131
132    /// Bash abort handle for bang (!) commands.
133    bash_abort_handle: Option<tokio::task::AbortHandle>,
134
135    /// Session persistence via AgentSession lifecycle layer.
136    session: Option<AgentSession>,
137
138    /// Footer (shared ownership - App mutates, TUI.root renders).
139    footer: Rc<RefCell<Footer>>,
140
141    /// Footer data provider (pull-based: git branch, extension statuses).
142    footer_provider: Rc<RefCell<FooterDataProvider>>,
143
144    /// Pending tool executions keyed by tool call ID.
145    /// Used to update ToolExecComponent when ToolResult arrives (pi's `pendingTools` Map).
146    pending_tools: HashMap<String, Weak<RefCell<crate::agent::ui::components::ToolExecComponent>>>,
147
148    /// Start times for pending tool calls, keyed by tool call ID.
149    /// Used to compute duration for bash and other tools.
150    tool_call_start_times: HashMap<String, std::time::Instant>,
151
152    /// Receivers for async invalidation notifications (edit tool preview).
153    /// Polled on each render cycle to trigger re-render of tool components.
154    invalidate_rxs: Vec<tokio::sync::mpsc::UnboundedReceiver<()>>,
155
156    /// Streaming assistant message component (pi's `streamingComponent`).
157    /// Created on first TextDelta, updated in-place, cleared on TurnEnd/AgentEnd.
158    streaming_component:
159        Option<Weak<RefCell<crate::agent::ui::components::AssistantMessageComponent>>>,
160
161    /// Working indicator.
162    working: WorkingIndicator,
163
164    /// Transient status text (pi-style: replaces previous status, not added to chat).
165    status_text: Option<String>,
166
167    /// Pending command result that needs TUI access (overlays etc.).
168    /// Set by handle_slash_command, consumed in the main loop where TUI is available.
169    pending_command_result: Option<CommandResult>,
170
171    /// Agent tools (for tool execution).
172    /// Extensions.
173    extensions: Arc<Vec<Box<dyn Extension>>>,
174    /// Skills loaded for the session (/skill:name expansion).
175    skills: Vec<yoagent::skills::Skill>,
176    /// API key for yoagent provider.
177    api_key: String,
178    /// Session info updater for /session command.
179    session_info: Option<std::sync::Arc<std::sync::Mutex<Option<SessionInfoInternal>>>>,
180
181    /// Auto-compact toggle state.
182    auto_compact: bool,
183
184    /// Settings reference for persisting toggle changes.
185    settings: crate::agent::settings::Settings,
186
187    /// Header component (welcome/onboarding). Stored as `Rc<RefCell>` so
188    /// handle_tools_expand can toggle its expanded state (matching pi's
189    /// behavior where setToolsExpanded expands both the header and all
190    /// expandable chat children).
191    header: Rc<RefCell<crate::agent::ui::components::HeaderComponent>>,
192
193    /// Session picker state (Some = picker is active).
194    session_picker: Option<crate::agent::ui::components::SessionPicker>,
195
196    /// Tracks the number of children in `chat_container` after the last
197    /// status message was added (pi-style `lastStatusSpacer`/`lastStatusText`).
198    /// Used by `show_status()` to replace consecutive status messages in-place
199    /// instead of appending indefinitely.
200    last_status_len: Option<usize>,
201    // ── Message rendering cache (avoids re-rendering messages every frame) ──
202    // Cache fields removed - messages now rendered via Components in chat_container.
203}
204
205impl App {
206    fn new(config: AppConfig, session: AgentSession) -> Self {
207        let mut agent_session = session;
208        let mut model_config = crate::agent::base_model_config(&config.model);
209        model_config.context_window =
210            crate::agent::compaction::get_model_context_window(&config.model) as u32;
211        agent_session.set_compaction_config(
212            config.api_key.clone(),
213            &config.model,
214            crate::agent::compaction::get_model_context_window(&config.model),
215            Some(model_config),
216        );
217        agent_session.set_auto_compact(config.settings.auto_compact.unwrap_or(true));
218        let (tx, rx) = mpsc::unbounded_channel();
219        use crate::agent::ui::theme::current_theme;
220        let theme = current_theme().clone();
221
222        let mut editor = ChatEditor::new(&theme, config.cwd.clone());
223
224        // Collect slash commands with argument completion callbacks
225        use crate::tui::autocomplete::AutocompleteItem as AutoAutocompleteItem;
226        use crate::tui::autocomplete::SlashCommand as AutoSlashCommand;
227        let auto_commands: Vec<AutoSlashCommand> = config
228            .extensions
229            .iter()
230            .flat_map(|e| e.commands())
231            .map(|cmd| {
232                let handler = cmd.handler;
233                AutoSlashCommand {
234                    name: cmd.name,
235                    description: Some(cmd.description),
236                    argument_hint: None,
237                    argument_completions: None,
238                    get_argument_completions: Some(std::sync::Arc::new(
239                        move |prefix: &str| -> Vec<AutoAutocompleteItem> {
240                            handler
241                                .argument_completions(prefix)
242                                .into_iter()
243                                .map(|item| AutoAutocompleteItem {
244                                    value: item.value,
245                                    label: item.label,
246                                    description: item.description,
247                                })
248                                .collect()
249                        },
250                    )),
251                }
252            })
253            .collect();
254        editor.set_slash_commands(auto_commands);
255
256        // Keep commands list for help overlay and unknown-command display.
257        let commands: Vec<(String, String)> = config
258            .extensions
259            .iter()
260            .flat_map(|e| e.commands())
261            .map(|c| (c.name, c.description))
262            .collect();
263
264        let editor = Rc::new(RefCell::new(editor));
265
266        let footer_provider = Rc::new(RefCell::new(FooterDataProvider::new(config.cwd.clone())));
267
268        let mut footer = Footer::new(
269            config.cwd.to_string_lossy().to_string(),
270            footer_provider.clone(),
271        );
272        footer.set_model(&config.model);
273        footer.set_model_supports_reasoning(config.model_supports_reasoning);
274        footer.set_thinking_level(config.thinking_level.clone());
275        footer.set_context_window(crate::agent::compaction::get_model_context_window(
276            &config.model,
277        ));
278
279        let footer = Rc::new(RefCell::new(footer));
280
281        // Load session messages
282        let context = agent_session.session().build_session_context();
283        let history_messages = context.messages.clone();
284
285        // Startup info: context files, skills, tools (pi-style loaded resources listing)
286        let mut resource_parts: Vec<String> = Vec::new();
287        if !config.context_files.is_empty() {
288            let ctx = config.context_files.join(", ");
289            resource_parts.push(format!("Context: {}", ctx));
290        }
291        if !config.skills.is_empty() {
292            let skill_names: Vec<&str> = config.skills.iter().map(|s| s.name.as_str()).collect();
293            resource_parts.push(format!("Skills: {}", skill_names.join(", ")));
294        }
295
296        // Build chat_container from AgentMessages directly (matching pi's renderSessionContext).
297        // Adjacent toolCall content + toolResult messages are paired into single
298        // ToolExecComponent so reloaded sessions look identical to live execution.
299        let cwd_string = config.cwd.to_string_lossy().to_string();
300        let chat_container =
301            std::rc::Rc::new(std::cell::RefCell::new(crate::tui::Container::new()));
302        {
303            let mut chat = chat_container.borrow_mut();
304
305            // Startup info component
306            if !resource_parts.is_empty() {
307                chat.add_child(std::boxed::Box::new(
308                    crate::agent::ui::components::InfoMessageComponent::new(
309                        resource_parts.join("  ·  "),
310                    ),
311                ));
312            }
313
314            rebuild_chat_from_messages(
315                &mut chat,
316                &history_messages,
317                &cwd_string,
318                config.hide_thinking,
319                config.collapse_tool_output,
320                &config.extensions,
321            );
322        }
323
324        let result = Self {
325            cwd: config.cwd,
326            model: config.model,
327            thinking_level: config.thinking_level,
328            system_prompt: config.system_prompt,
329            theme,
330            commands,
331            available_models: config.available_models,
332            chat_container,
333            pending_tools: HashMap::new(),
334            tool_call_start_times: HashMap::new(),
335            invalidate_rxs: Vec::new(),
336            streaming_component: None,
337
338            status_section: std::rc::Rc::new(std::cell::RefCell::new(
339                crate::tui::components::DynamicLines::new(),
340            )),
341            working_section: std::rc::Rc::new(std::cell::RefCell::new(
342                crate::tui::components::DynamicLines::new(),
343            )),
344            editor,
345            event_tx: tx,
346            event_rx: rx,
347            is_streaming: false,
348            pending_submit: None,
349            pending_compact: None,
350            pending_auto_compact: false,
351            agent: None,
352            forward_handle: None,
353            pending_command_result: None,
354            hide_thinking: config.hide_thinking,
355            collapse_tool_output: config.collapse_tool_output,
356            tools_expanded: !config.collapse_tool_output,
357            scroll_offset: 0,
358            last_clear_time: std::time::Instant::now(),
359
360            should_quit: false,
361            pending_tool_executions: 0,
362            bash_abort_handle: None,
363            session: Some(agent_session),
364            footer,
365            footer_provider,
366            working: WorkingIndicator::new(),
367            extensions: Arc::new(config.extensions),
368
369            skills: config.skills,
370            session_info: config.session_info,
371            api_key: config.api_key,
372            settings: config.settings,
373            auto_compact: true,
374            status_text: None,
375            header: Rc::new(RefCell::new(
376                crate::agent::ui::components::HeaderComponent::new(),
377            )),
378            session_picker: None,
379            last_status_len: None,
380        };
381
382        // Initial session info for /session command
383        result.update_session_info();
384
385        // Initialize footer stats and session name from session
386        if let Some(ref s) = result.session {
387            result.footer.borrow_mut().refresh_from_session(s.session());
388        }
389
390        result
391    }
392
393    /// Update the session info shared with CommandsExtension for /session display.
394    fn update_session_info(&self) {
395        if let Some(ref session) = self.session
396            && let Some(ref info) = self.session_info
397        {
398            let si = crate::builtin::commands::compute_session_info(session.session());
399            if let Ok(mut guard) = info.lock() {
400                *guard = Some(si);
401            }
402        }
403    }
404
405    /// Refresh git branch for footer display.
406    /// Called on AgentStart to match pi's FooterDataProvider.onBranchChange.
407    fn refresh_git_branch(&self) {
408        self.footer_provider.borrow_mut().refresh_git_branch();
409    }
410
411    /// Clear all transient session state when switching to a new session.
412    fn clear_session_state(&mut self) {
413        self.chat_container.borrow_mut().clear();
414        self.streaming_component = None;
415        self.pending_tools.clear();
416        self.tool_call_start_times.clear();
417        self.pending_submit = None;
418    }
419
420    /// Rebuild chat and agent messages from the current session context.
421    /// Used after compaction to update the UI and keep the agent in sync.
422    fn rebuild_from_session_context(&mut self) {
423        if let Some(ref agent_session) = self.session {
424            let context = agent_session.session().build_session_context();
425            {
426                let mut chat = self.chat_container.borrow_mut();
427                rebuild_chat_from_messages(
428                    &mut chat,
429                    &context.messages,
430                    &self.cwd.to_string_lossy(),
431                    self.hide_thinking,
432                    self.collapse_tool_output,
433                    &self.extensions,
434                );
435            }
436            if let Some(ref mut agent) = self.agent {
437                agent.replace_messages(context.messages);
438            }
439        }
440    }
441
442    /// Switch to a different session: open the file, clear state, rebuild chat.
443    fn switch_to_session(&mut self, new_session: AgentSession) {
444        let ctx = new_session.session().build_session_context();
445        self.clear_session_state();
446        rebuild_chat_from_messages(
447            &mut self.chat_container.borrow_mut(),
448            &ctx.messages,
449            &self.cwd.to_string_lossy(),
450            self.hide_thinking,
451            self.collapse_tool_output,
452            &self.extensions,
453        );
454        // Refresh footer cached stats for the switched-to session
455        self.footer
456            .borrow_mut()
457            .refresh_from_session(new_session.session());
458
459        self.session = Some(new_session);
460        self.agent = None;
461        self.update_session_info();
462    }
463}
464
465/// Run the interactive UI.
466pub async fn run(config: AppConfig, session: AgentSession) -> anyhow::Result<()> {
467    // Initialize theme system
468    crate::agent::ui::theme::init_theme(Some("dark"), false);
469
470    let mut term = ProcessTerminal::new();
471    let mut stdout = std::io::stdout();
472
473    // Main-screen mode (like pi) - no alternate screen, no clear.
474    // Content writes from current cursor position (after shell prompt).
475    // Terminal scrolls naturally, editor/footer appear at the bottom.
476    term.start(&mut stdout)?;
477    term.hide_cursor(&mut stdout)?;
478    term.set_color_scheme_notifications(&mut stdout, true)?;
479    crate::tui::terminal::start_stdin_reader();
480
481    let mut tui = TUI::new();
482    // Disable clear_on_shrink to avoid full redraws during streaming
483    // (content grows/shrinks frequently as pending text is flushed).
484    tui.set_clear_on_shrink(false);
485    let mut app = App::new(config, session);
486
487    // Focus the editor so it emits the cursor marker for Screen tracking
488    app.editor.borrow_mut().editor.set_focused(true);
489
490    // Set up the component tree in TUI.root (matching pi's TUI.extend(Container))
491    // Order: header → chat_container (messages) → pending → status → queued → working → editor → footer
492    tui.root.add_child(std::boxed::Box::new(
493        crate::tui::components::RcRefCellComponent(
494            app.header.clone() as Rc<RefCell<dyn Component>>,
495        ),
496    ));
497    tui.root.add_child(std::boxed::Box::new(
498        crate::tui::components::RcRefCellComponent(app.chat_container.clone()
499            as std::rc::Rc<std::cell::RefCell<dyn crate::tui::Component>>),
500    ));
501    tui.root.add_child(std::boxed::Box::new(
502        crate::tui::components::RcRefCellComponent(app.status_section.clone()
503            as std::rc::Rc<std::cell::RefCell<dyn crate::tui::Component>>),
504    ));
505    tui.root.add_child(std::boxed::Box::new(
506        crate::tui::components::RcRefCellComponent(app.working_section.clone()
507            as std::rc::Rc<std::cell::RefCell<dyn crate::tui::Component>>),
508    ));
509    tui.root
510        .add_child(std::boxed::Box::new(EditorComponent(app.editor.clone())));
511    tui.root
512        .add_child(std::boxed::Box::new(FooterComponent(app.footer.clone())));
513
514    // Initialize editor border color
515    app.editor.borrow_mut().update_border_color(
516        app.thinking_level.as_deref(),
517        &app.theme as &dyn crate::tui::Theme,
518    );
519
520    // Cache terminal dimensions to avoid expensive syscall on every frame.
521    // Only re-query when a resize event is detected or periodically.
522    let mut cols: u16 = 80;
523    let mut rows: u16 = 24;
524    let mut dirty = true; // force initial render
525
526    loop {
527        // Drain agent events FIRST so state (is_streaming, pending_auto_compact) is
528        // up-to-date before handle_input checks it. Prevents races where a terminal
529        // event arrives in the same cycle as AgentEnd — handle_input would see stale
530        // is_streaming=true and steer the message instead of starting a new turn.
531        let mut had_event = false;
532        while let Ok(event) = app.event_rx.try_recv() {
533            handle_agent_event(&mut app, event);
534            had_event = true;
535        }
536        if had_event {
537            dirty = true;
538        }
539
540        // Drain terminal events (non-blocking — stdin reader runs on a
541        // separate thread). The stdin thread is already decoupled from the
542        // main loop, so we just drain whatever has arrived since last check.
543        loop {
544            match terminal::try_recv_terminal_event() {
545                Some(terminal::TerminalEvent::Key(key)) => {
546                    // TUI overlay routing first (overlays get first crack at input)
547                    if !tui.route_input(&key) {
548                        handle_input(&mut app, &mut tui, &mut term, &key);
549                    }
550                }
551                Some(terminal::TerminalEvent::Paste(content)) => {
552                    // Route to focused overlay first (e.g. Input in settings),
553                    // fall back to the main Editor.
554                    if !tui.route_paste(&content) {
555                        app.editor.borrow_mut().editor.handle_paste(&content);
556                    }
557                }
558                Some(terminal::TerminalEvent::Resize(w, h)) => {
559                    app.editor.borrow_mut().editor.set_terminal_rows(h as usize);
560                    tui.set_dimensions(w as usize, h as usize);
561                }
562                None => break,
563            }
564            dirty = true;
565        }
566
567        // Re-drain agent events that arrived during terminal event processing.
568        // AgentEnd (which sets is_streaming=false) can land between the initial
569        // drain above and the user hitting Enter — processing terminal events
570        // can take real time (edit operations, overlays, etc). Without this,
571        // submit_message may see a stale is_streaming=true and incorrectly try
572        // to steer a finished agent.
573        while let Ok(event) = app.event_rx.try_recv() {
574            handle_agent_event(&mut app, event);
575            dirty = true;
576        }
577
578        // Recover Agent state BEFORE submitting any new prompt or running
579        // auto-compact. This ensures agent.finish() restores messages from
580        // the completed JoinHandle first, so that subsequent
581        // replace_messages calls (from handle_auto_compact) don't get
582        // overwritten.
583        if app.forward_handle.as_ref().is_some_and(|h| h.is_finished()) {
584            app.forward_handle.take();
585            if let Some(ref mut agent) = app.agent {
586                // The JoinHandle is resolved, so this returns instantly.
587                agent.finish().await;
588            }
589        }
590
591        // Handle pending agent submission (async).
592        // During streaming, submit_message uses agent.steer() directly so
593        // pending_submit is only set for the idle path. Processed here as
594        // soon as is_streaming becomes false.
595        if !app.is_streaming
596            && let Some(text) = app.pending_submit.take()
597        {
598            start_agent_loop(&mut app, text).await;
599            dirty = true;
600        }
601
602        // Handle pending manual compaction (async)
603        if let Some(custom_instructions) = app.pending_compact.take() {
604            handle_compact_command(&mut app, custom_instructions).await;
605            dirty = true;
606        }
607
608        // Pi-compatible: auto-compaction check after agent ends.
609        // Runs after agent.finish() to ensure replace_messages in
610        // handle_auto_compact doesn't get overwritten.
611        if app.pending_auto_compact {
612            app.pending_auto_compact = false;
613            handle_auto_compact(&mut app).await;
614            dirty = true;
615        }
616
617        // Handle pending command results that need TUI access (overlays, etc.)
618        if let Some(result) = app.pending_command_result.take() {
619            match result {
620                CommandResult::ShowHelp => {
621                    show_help_overlay(&mut app, &mut tui);
622                }
623                CommandResult::OpenSessionSelector => {
624                    // Open session picker
625                    let mut picker = crate::agent::ui::components::SessionPicker::new();
626                    let repo = crate::agent::DefaultSessionRepo::new();
627                    picker.load_sessions(&repo);
628                    app.session_picker = Some(picker);
629                    app.status_text = None;
630                }
631                CommandResult::OpenSettings => {
632                    chat_add(
633                        &mut app,
634                        std::boxed::Box::new(InfoMessageComponent::new(
635                            "Settings menu - not yet implemented.",
636                        )),
637                    );
638                }
639                CommandResult::ScopedModels => {
640                    chat_add(
641                        &mut app,
642                        std::boxed::Box::new(InfoMessageComponent::new(
643                            "Scoped models - not yet implemented.",
644                        )),
645                    );
646                }
647                CommandResult::Login { .. } => {
648                    chat_add(
649                        &mut app,
650                        std::boxed::Box::new(InfoMessageComponent::new(
651                            "Login dialog - not yet implemented.",
652                        )),
653                    );
654                }
655                _ => {}
656            }
657            dirty = true;
658        }
659
660        // Poll async invalidation receivers (edit tool preview, etc.)
661        app.invalidate_rxs.retain_mut(|rx| {
662            if rx.try_recv().is_ok() {
663                dirty = true;
664                true
665            } else {
666                !rx.is_closed()
667            }
668        });
669
670        // Check terminal size only when we're about to render
671        // (avoids expensive ioctl syscall on idle frames)
672        if dirty && let Ok((w, h)) = term.size() {
673            app.editor.borrow_mut().editor.set_terminal_rows(h as usize);
674            cols = w;
675            rows = h;
676        }
677
678        // Tick the working indicator - sets dirty when spinner advances
679        if app.working.tick() {
680            dirty = true;
681        }
682
683        // Tick active tool timers (bash elapsed display, matching pi's setInterval(1000))
684        let mut tools_to_remove: Vec<String> = Vec::new();
685        for (id, weak) in app.pending_tools.iter() {
686            if let Some(comp) = weak.upgrade() {
687                if comp.borrow_mut().tick_timer() {
688                    dirty = true;
689                }
690            } else {
691                tools_to_remove.push(id.clone());
692            }
693        }
694        for id in tools_to_remove {
695            app.pending_tools.remove(&id);
696        }
697
698        // Compose and render only when state has changed
699        if dirty {
700            // Update section components from compose_ui
701            compose_ui(&mut app, cols as usize);
702            tui.set_dimensions(cols as usize, rows as usize);
703            tui.render(cols as usize, rows as usize, &mut stdout)?;
704            dirty = false;
705        }
706
707        // Idle backpressure: sleep briefly so we don't busy-wait when idle.
708        // Active frames (dirty, streaming, working spinner) run at ~60fps;
709        // idle frames pace at ~20fps to save CPU/battery.
710        tokio::time::sleep(if dirty || app.is_streaming || app.working.should_show() {
711            Duration::from_millis(16)
712        } else {
713            Duration::from_millis(50)
714        })
715        .await;
716
717        // Pi: clear transient status after rendering
718        app.status_text = None;
719
720        if app.should_quit {
721            break;
722        }
723    }
724
725    // Cleanup - move cursor past all rendered content so the shell prompt
726    // appears on a fresh line after the footer (matching pi's stop() behavior).
727    tui.finalize(&mut stdout)?;
728    term.set_color_scheme_notifications(&mut stdout, false)?;
729    term.show_cursor(&mut stdout)?;
730    term.stop(&mut stdout)?;
731
732    Ok(())
733}
734
735/// Update UI section components from app state.
736/// Each section is a child of TUI.root rendered in the correct order.
737///
738/// Layout (top to bottom):
739///   header → chat_container (messages) → pending → status → queued → working → editor → footer
740fn compose_ui(app: &mut App, width: usize) {
741    // ── Session picker ──
742    if let Some(ref picker) = app.session_picker {
743        let (_lines, _cursor_y) = picker.render(width, &app.theme as &dyn crate::tui::Theme);
744        // Clear chat container when picker is active
745        app.chat_container.borrow_mut().clear();
746        app.status_section.borrow_mut().set_lines(vec![]);
747        app.working_section.borrow_mut().set_lines(vec![]);
748        return;
749    }
750
751    // ── Transient status text (pi-style: replaces previous status, not added to chat) ──
752    let mut status_lines = Vec::new();
753    if let Some(ref status) = app.status_text {
754        let line = app.theme.fg_key(ThemeKey::Dim, &format!(" {}", status));
755        status_lines.push(crate::agent::ui::render_utils::pad_to_width(&line, width));
756    }
757
758    // ── Queued message indicator (pi-style: shows queued messages during streaming) ──
759    if app.is_streaming {
760        // Show pending_submit if set (idle path, before agent loop starts)
761        if let Some(ref msg) = app.pending_submit {
762            let preview = if msg.len() > 60 {
763                format!("{}…", &msg[..60])
764            } else {
765                msg.clone()
766            };
767            let line = app
768                .theme
769                .fg_key(ThemeKey::Dim, &format!(" 📝 queued: {}", preview));
770            status_lines.push(crate::agent::ui::render_utils::pad_to_width(&line, width));
771        }
772    }
773    app.status_section.borrow_mut().set_lines(status_lines);
774
775    // ── Working indicator (pi-style: blank line + spinner before editor) ──
776    let mut working_lines = Vec::new();
777    let wl = app.working.render(width);
778    working_lines.extend(wl);
779    app.working_section.borrow_mut().set_lines(working_lines);
780}
781
782// Helper: create an AgentMessage for a user text input (used for steer/follow_up).
783fn user_agent_message(text: &str) -> yoagent::types::AgentMessage {
784    yoagent::types::AgentMessage::Llm(yoagent::types::Message::User {
785        content: vec![yoagent::types::Content::Text {
786            text: text.to_string(),
787        }],
788        timestamp: yoagent::types::now_ms(),
789    })
790}
791
792/// Handle keyboard input. Mirrors pi's InteractiveMode key dispatch:
793///
794/// 1. Overlays handled via TUI.route_input - checked first in event loop
795/// 2. ChatEditor::handle_input checks app-level keys and returns InputAction
796/// 3. App.rs matches on InputAction to perform side effects
797///
798/// This keeps text-editing logic in the Editor component (via ChatEditor)
799/// and app-level side effects (aborting agents, toggling settings, etc.) here.
800fn handle_input(app: &mut App, tui: &mut TUI, term: &mut ProcessTerminal, key: &KeyEvent) {
801    // ── Session picker input handling ──
802    if app.session_picker.is_some() {
803        handle_session_picker_input(app, key);
804        return;
805    }
806
807    // ── Check if any TUI overlay is active (help, model selector, etc.) ──
808    if tui.has_overlays() {
809        tui.pop_overlay();
810        return;
811    }
812
813    // ── Route input to root container children (header, etc.) ──
814    // Root children (header → chat_container → pending → etc.) get a chance
815    // to handle input before the editor. Components that don't consume the
816    // event return false so it flows through to the editor.
817    if tui.root.handle_input(key) {
818        return;
819    }
820
821    // ── Dispatch to ChatEditor (mirrors pi's CustomEditor.handleInput) ──
822    // Borrow the editor in a let binding so the RefMut drops before we mutate App.
823    let action = app.editor.borrow_mut().handle_input(key);
824    match action {
825        InputAction::Handled => {}
826        InputAction::Escape => {
827            // Pi-style: abort streaming or bash, else clear editor
828            if app.is_streaming {
829                interrupt_streaming(app);
830            } else {
831                app.editor.borrow_mut().editor.set_text("");
832            }
833        }
834        InputAction::Clear => {
835            handle_clear(app);
836        }
837        InputAction::Exit => {
838            app.should_quit = true;
839        }
840        InputAction::ThinkingCycle => {
841            handle_thinking_cycle(app);
842        }
843        InputAction::ModelSelector => {
844            open_model_selector(app, tui);
845        }
846        InputAction::ModelCycleForward => {
847            handle_model_cycle(app, 1);
848        }
849        InputAction::ModelCycleBackward => {
850            handle_model_cycle(app, -1);
851        }
852        InputAction::ToggleThinking => {
853            app.hide_thinking = !app.hide_thinking;
854            // Propagate to ALL existing components in chat container (matching pi)
855            {
856                let mut chat = app.chat_container.borrow_mut();
857                for child in chat.children_mut().iter_mut() {
858                    child.set_hide_thinking(app.hide_thinking);
859                }
860            }
861            // Update streaming component if it exists
862            if let Some(weak) = app.streaming_component.as_ref().and_then(|w| w.upgrade()) {
863                weak.borrow_mut().set_hide_thinking(app.hide_thinking);
864            }
865            // Persist only the affected field (incremental save)
866            app.settings.set_hide_thinking(Some(app.hide_thinking));
867            if let Err(e) = app.settings.save() {
868                app.status_text = Some(format!("Failed to save thinking visibility: {}", e));
869            }
870            show_status(
871                app,
872                if app.hide_thinking {
873                    "Thinking blocks: hidden".to_string()
874                } else {
875                    "Thinking blocks: visible".to_string()
876                },
877            );
878        }
879        InputAction::ToolsExpand => {
880            handle_tools_expand(app);
881        }
882        InputAction::EditorExternal => {
883            handle_editor_external(app, tui, term);
884        }
885        InputAction::Help => {
886            show_help_overlay(app, tui);
887        }
888        InputAction::Submit(text) => {
889            submit_message(app, text);
890        }
891        InputAction::FollowUp(text) => {
892            handle_follow_up(app, text);
893        }
894        InputAction::Dequeue => {
895            // Restore queued message back to editor (pi's app.message.dequeue)
896            if let Some(msg) = app.pending_submit.take() {
897                app.editor.borrow_mut().editor.set_text(&msg);
898                app.status_text = Some("Queued message restored to editor".into());
899            } else {
900                app.status_text = Some("No queued message".into());
901            }
902        }
903        InputAction::CompactToggle => {
904            handle_compact_toggle(app);
905        }
906    }
907}
908
909// =============================================================================
910// New action handlers (pi-compatible)
911// =============================================================================
912
913/// Handle Ctrl+C: clear editor (double-press within 500ms = exit).
914fn handle_clear(app: &mut App) {
915    let now = std::time::Instant::now();
916    let elapsed = now.duration_since(app.last_clear_time);
917    app.last_clear_time = now;
918
919    if app.is_streaming {
920        interrupt_streaming(app);
921    } else if elapsed.as_millis() < 500 {
922        // Double Ctrl+C within 500ms = exit (pi-style)
923        app.should_quit = true;
924    } else {
925        app.editor.borrow_mut().editor.set_text("");
926        app.status_text = Some("Cleared".into());
927    }
928}
929
930/// Cycle thinking level: off → low → medium → high → xhigh → off
931fn handle_thinking_cycle(app: &mut App) {
932    if app.available_models.is_empty() && app.model.is_empty() {
933        app.status_text = Some("No model selected".into());
934        return;
935    }
936
937    let current = app.thinking_level.as_deref().unwrap_or("off");
938    let next = match THINKING_LEVELS.iter().position(|&l| l == current) {
939        Some(pos) => THINKING_LEVELS[(pos + 1) % THINKING_LEVELS.len()],
940        None => "off",
941    };
942
943    app.thinking_level = Some(next.to_string());
944    app.footer
945        .borrow_mut()
946        .set_thinking_level(Some(next.to_string()));
947    app.editor
948        .borrow_mut()
949        .update_border_color(Some(next), &app.theme as &dyn crate::tui::Theme);
950    app.settings
951        .set_default_thinking_level(Some(next.to_string()));
952    if let Err(e) = app.settings.save() {
953        app.status_text = Some(format!("Failed to save thinking level: {}", e));
954    }
955    // Record the change in the session and update the persistent agent
956    if let Some(ref mut agent_session) = app.session {
957        agent_session.on_thinking_level_change(next);
958    }
959    app.status_text = Some(format!("Thinking level: {}", next));
960}
961
962/// Cycle model forward (dir=1) or backward (dir=-1).
963fn handle_model_cycle(app: &mut App, dir: isize) {
964    let n = app.available_models.len();
965    if n == 0 {
966        app.status_text = Some("No models available".into());
967        return;
968    }
969
970    let current_idx = app.available_models.iter().position(|m| m == &app.model);
971
972    let next_idx = match current_idx {
973        Some(idx) => (idx as isize + dir).rem_euclid(n as isize) as usize,
974        None => 0,
975    };
976
977    app.model = app.available_models[next_idx].clone();
978    app.footer.borrow_mut().set_model(&app.model);
979    // All rab models support reasoning (deepseek-v4-flash, deepseek-v4-pro).
980    app.footer.borrow_mut().set_model_supports_reasoning(true);
981    // Record the change in the session and update the persistent agent
982    if let Some(ref mut agent_session) = app.session {
983        agent_session.on_model_change("opencode-go", &app.model);
984    }
985    app.status_text = Some(format!("Model: {}", app.model));
986}
987
988/// Toggle all tool output expansion (Ctrl+O).
989/// Mirrors pi's `toggleToolOutputExpansion()` which iterates all chat_container
990/// children and calls `setExpanded()` on `Expandable` components.
991fn handle_tools_expand(app: &mut App) {
992    app.tools_expanded = !app.tools_expanded;
993    app.collapse_tool_output = !app.tools_expanded;
994
995    // Expand/collapse header (welcome/onboarding) - matching pi's setToolsExpanded
996    // which expands both the active header and all expandable chat children.
997    app.header.borrow_mut().set_expanded(app.tools_expanded);
998
999    // Propagate to all children in chat_container
1000    let mut chat = app.chat_container.borrow_mut();
1001    for child in chat.children_mut().iter_mut() {
1002        child.set_expanded(app.tools_expanded);
1003    }
1004    drop(chat);
1005
1006    app.settings
1007        .set_collapse_tool_output(Some(app.collapse_tool_output));
1008    if let Err(e) = app.settings.save() {
1009        app.status_text = Some(format!("Failed to save tool output setting: {}", e));
1010    }
1011    show_status(
1012        app,
1013        if app.tools_expanded {
1014            "Tool output: expanded".to_string()
1015        } else {
1016            "Tool output: collapsed".to_string()
1017        },
1018    );
1019}
1020
1021/// Open external editor ($VISUAL / $EDITOR) for current editor content.
1022/// Suspends the TUI (disables raw mode), runs the editor, then resumes.
1023fn handle_editor_external(app: &mut App, tui: &mut TUI, term: &mut ProcessTerminal) {
1024    let editor_cmd = std::env::var("VISUAL")
1025        .or_else(|_| std::env::var("EDITOR"))
1026        .unwrap_or_default();
1027
1028    if editor_cmd.is_empty() {
1029        app.status_text = Some("No editor configured. Set $VISUAL or $EDITOR.".into());
1030        return;
1031    }
1032
1033    let tmp_dir = std::env::temp_dir();
1034    let tmp_file = tmp_dir.join(format!(
1035        "rab-editor-{}.md",
1036        std::time::SystemTime::now()
1037            .duration_since(std::time::UNIX_EPOCH)
1038            .map(|d| d.as_nanos())
1039            .unwrap_or(0)
1040    ));
1041
1042    let current_text = app.editor.borrow().editor.get_text();
1043    if let Err(e) = std::fs::write(&tmp_file, &current_text) {
1044        app.status_text = Some(format!("Failed to write temp file: {}", e));
1045        return;
1046    }
1047
1048    let parts: Vec<&str> = editor_cmd.split(' ').collect();
1049    let (editor, args) = parts.split_first().unwrap_or((&"", &[]));
1050
1051    // ── Suspend TUI ──
1052    app.status_text = Some(format!("Opening {} ...", editor_cmd));
1053    let mut suspend_buf = Vec::new();
1054    let _ = term.stop(&mut suspend_buf);
1055    let _ = term.show_cursor(&mut suspend_buf);
1056    if !suspend_buf.is_empty() {
1057        let stdout = std::io::stdout();
1058        let mut handle = stdout.lock();
1059        let _ = handle.write_all(&suspend_buf);
1060        let _ = handle.flush();
1061    }
1062
1063    // Stop the stdin reader thread (uses poll() with timeout, exits cleanly).
1064    crate::tui::terminal::stop_stdin_reader();
1065    crate::tui::terminal::join_stdin_reader();
1066
1067    // ── Run editor ──
1068    let status = std::process::Command::new(editor)
1069        .args(args)
1070        .arg(&tmp_file)
1071        .status();
1072
1073    // ── Resume TUI ──
1074    let mut resume_buf = Vec::new();
1075    let _ = term.start(&mut resume_buf);
1076    let _ = term.hide_cursor(&mut resume_buf);
1077    if !resume_buf.is_empty() {
1078        let stdout = std::io::stdout();
1079        let mut handle = stdout.lock();
1080        let _ = handle.write_all(&resume_buf);
1081        let _ = handle.flush();
1082    }
1083    // Restart stdin reader (after raw mode is active)
1084    crate::tui::terminal::start_stdin_reader();
1085    // Force full redraw
1086    tui.request_render();
1087
1088    match status {
1089        Ok(status) if status.success() => {
1090            if let Ok(new_content) = std::fs::read_to_string(&tmp_file) {
1091                let trimmed = new_content.trim_end_matches('\n').to_string();
1092                app.editor.borrow_mut().editor.set_text(&trimmed);
1093                app.editor.borrow_mut().check_autocomplete();
1094            }
1095            let _ = std::fs::remove_file(&tmp_file);
1096            app.status_text = Some("Editor closed".into());
1097        }
1098        Ok(_) => {
1099            let _ = std::fs::remove_file(&tmp_file);
1100            app.status_text = Some("Editor exited with non-zero status".into());
1101        }
1102        Err(e) => {
1103            let _ = std::fs::remove_file(&tmp_file);
1104            app.status_text = Some(format!("Failed to launch editor: {}", e));
1105        }
1106    }
1107}
1108
1109/// Toggle auto-compact indicator (Ctrl+Shift+C).
1110/// Pi-compatible: syncs with AgentSession and persists to settings.
1111fn handle_compact_toggle(app: &mut App) {
1112    app.auto_compact = !app.auto_compact;
1113    app.footer.borrow_mut().set_auto_compact(app.auto_compact);
1114
1115    // Sync with AgentSession (pi-compatible: compaction_settings.enabled)
1116    if let Some(ref mut s) = app.session {
1117        s.set_auto_compact(app.auto_compact);
1118    }
1119
1120    // Persist to settings
1121    app.settings.set_auto_compact(Some(app.auto_compact));
1122    if let Err(e) = app.settings.save() {
1123        eprintln!("Warning: failed to save auto_compact setting: {}", e);
1124    }
1125
1126    app.status_text = Some(if app.auto_compact {
1127        "Auto-compact: on".into()
1128    } else {
1129        "Auto-compact: off".into()
1130    });
1131}
1132
1133/// Queue a follow-up message (Alt+Enter) during streaming.
1134/// Uses yoagent's native `follow_up()` — the agent loop's outer loop
1135/// picks it up naturally after the current inner loop finishes.
1136pub fn handle_follow_up(app: &mut App, text: String) {
1137    let trimmed = text.trim().to_string();
1138    if trimmed.is_empty() {
1139        return;
1140    }
1141
1142    if app.is_streaming && app.agent.as_ref().is_some_and(|a| a.is_streaming()) {
1143        let follow_msg = user_agent_message(&trimmed);
1144        if let Some(ref agent) = app.agent {
1145            agent.follow_up(follow_msg);
1146            app.status_text = Some("Follow-up queued — will send when agent finishes".into());
1147        }
1148    } else {
1149        // Not streaming — submit directly
1150        if app.is_streaming {
1151            app.is_streaming = false;
1152        }
1153        submit_message(app, trimmed);
1154    }
1155}
1156
1157/// Interrupt streaming agent and restore queued messages to editor.
1158fn interrupt_streaming(app: &mut App) {
1159    // Cooperatively cancel the running agent loop (fires cancel token)
1160    if let Some(ref agent) = app.agent {
1161        agent.abort();
1162    }
1163    // Kill the forwarding task
1164    if let Some(handle) = app.forward_handle.take() {
1165        handle.abort();
1166    }
1167    if let Some(handle) = app.bash_abort_handle.take() {
1168        handle.abort();
1169    }
1170    // Drop the agent — its tools were moved into the aborted loop and are lost.
1171    // A fresh agent will be created from session on the next turn.
1172    app.agent = None;
1173    app.is_streaming = false;
1174    app.working.stop();
1175    app.footer.borrow_mut().set_streaming(false);
1176
1177    // Rebuild chat from session (authoritative store after abort)
1178    if let Some(ref s) = app.session {
1179        let ctx = s.session().build_session_context();
1180        let mut chat = app.chat_container.borrow_mut();
1181        rebuild_chat_from_messages(
1182            &mut chat,
1183            &ctx.messages,
1184            &app.cwd.to_string_lossy(),
1185            app.hide_thinking,
1186            app.collapse_tool_output,
1187            &app.extensions,
1188        );
1189    }
1190
1191    app.status_text = Some("Interrupted".into());
1192}
1193
1194/// Open the model selector overlay.
1195fn open_model_selector(app: &mut App, tui: &mut TUI) {
1196    let models = app.available_models.clone();
1197    let current = app.model.clone();
1198    let selector = ModelSelector::new(models, &current, &app.theme);
1199    tui.show_overlay(Box::new(selector), Default::default());
1200}
1201
1202fn show_help_overlay(app: &mut App, tui: &mut TUI) {
1203    let mut overlay = crate::agent::ui::help::HelpOverlay::new(&app.theme);
1204    overlay.set_commands(app.commands.clone());
1205    tui.show_overlay(Box::new(overlay), Default::default());
1206}
1207
1208/// Submit or queue a user message.
1209/// When streaming, sets pending_submit which is deferred until the current
1210/// turn finishes (the main loop skips start_agent_loop while is_streaming).
1211/// When idle, starts a new agent loop immediately.
1212fn submit_message(app: &mut App, message: String) {
1213    app.scroll_offset = 0;
1214    let trimmed = message.trim().to_string();
1215
1216    // Don't submit empty messages (pi-style)
1217    if trimmed.is_empty() {
1218        return;
1219    }
1220
1221    // Handle /skill:name [args] expansion (pi-style: before command dispatch)
1222    if trimmed.starts_with("/skill:") {
1223        let expanded = expand_skill_command(&trimmed, &app.skills);
1224        if app.is_streaming && app.agent.as_ref().is_some_and(|a| a.is_streaming()) {
1225            let steer_msg = user_agent_message(&expanded);
1226            if let Some(ref agent) = app.agent {
1227                agent.steer(steer_msg);
1228                app.status_text = Some("Skill steering message sent".into());
1229            }
1230            return;
1231        }
1232        if app.is_streaming {
1233            // Stale streaming flag — reset
1234            app.is_streaming = false;
1235            app.working.stop();
1236            app.footer.borrow_mut().set_streaming(false);
1237        }
1238        app.pending_submit = Some(expanded);
1239        return;
1240    }
1241
1242    // Handle /commands (need TUI from app for overlays)
1243    if trimmed.starts_with('/') {
1244        handle_slash_command(app, &trimmed);
1245        return;
1246    }
1247
1248    // Handle ! and !! bang commands
1249    if let Some((cmd, _exclude)) = parse_bang_command(&trimmed) {
1250        handle_bang_command(app, cmd);
1251        return;
1252    }
1253
1254    if app.is_streaming {
1255        // When streaming, queue via steer(). The agent loop picks it up
1256        // between tool calls or after the current assistant turn, then
1257        // continues processing. Do NOT add to chat here — MessageStart
1258        // handler adds it when the agent loop processes the queued message.
1259        if app.agent.as_ref().is_some_and(|a| a.is_streaming()) {
1260            let steer_msg = user_agent_message(&trimmed);
1261            if let Some(ref agent) = app.agent {
1262                agent.steer(steer_msg);
1263                app.status_text = Some("Steering message sent — will be processed next".into());
1264            }
1265            // Reset overflow recovery for the steer'd message
1266            if let Some(ref mut s) = app.session {
1267                s.reset_overflow_recovery();
1268            }
1269            return; // Don't set pending_submit — agent loop handles this
1270        } else {
1271            // Stale streaming flag — agent task finished but is_streaming
1272            // not reset. Fall through to normal submission path.
1273            app.is_streaming = false;
1274            app.working.stop();
1275            app.footer.borrow_mut().set_streaming(false);
1276        }
1277    }
1278
1279    // Pi-compatible: reset overflow recovery state at the start of each turn
1280    if let Some(ref mut s) = app.session {
1281        s.reset_overflow_recovery();
1282    }
1283
1284    // Queue for async start in the main loop
1285    app.pending_submit = Some(trimmed);
1286}
1287
1288/// Actually start an agent loop (not queued).
1289/// Uses the persistent Agent on AgentSession (pi-compatible).
1290/// Build a fresh Agent with the given messages and app configuration.
1291fn build_fresh_agent(
1292    model: &str,
1293    api_key: &str,
1294    system_prompt: &str,
1295    thinking_level: yoagent::types::ThinkingLevel,
1296    messages: Vec<yoagent::types::AgentMessage>,
1297    extensions: &[Box<dyn Extension>],
1298) -> yoagent::agent::Agent {
1299    let mc = crate::agent::base_model_config(model);
1300
1301    let tools: Vec<Box<dyn yoagent::types::AgentTool>> = extensions
1302        .iter()
1303        .flat_map(|ext| ext.tools())
1304        .map(|twm| Box::new(twm) as Box<dyn yoagent::types::AgentTool>)
1305        .collect();
1306
1307    yoagent::agent::Agent::new(yoagent::provider::OpenAiCompatProvider)
1308        .with_model(model)
1309        .with_api_key(api_key)
1310        .with_model_config(mc)
1311        .with_system_prompt(system_prompt)
1312        .with_thinking(thinking_level)
1313        .with_messages(messages)
1314        .with_tools(tools)
1315        .without_context_management()
1316}
1317
1318/// Map rab's thinking level string to yoagent's ThinkingLevel enum.
1319fn map_thinking_level(level: Option<&str>) -> yoagent::types::ThinkingLevel {
1320    match level {
1321        Some("off") => yoagent::types::ThinkingLevel::Off,
1322        Some("low") => yoagent::types::ThinkingLevel::Low,
1323        Some("medium") => yoagent::types::ThinkingLevel::Medium,
1324        Some("high") | Some("xhigh") => yoagent::types::ThinkingLevel::High,
1325        _ => yoagent::types::ThinkingLevel::High,
1326    }
1327}
1328
1329/// Start an agent turn asynchronously. Called from the main loop only when
1330/// the agent is idle (the main loop guards with `!app.is_streaming`).
1331/// Reuses the existing agent across turns (single-agent model) so that
1332/// steer/follow-up queues and in-flight tool state survive across turns.
1333/// If no agent exists yet (first turn), creates a fresh one.
1334/// Messages are always synced from the session (error-filtered source) at
1335/// the start of each turn to avoid leaking transient provider errors.
1336async fn start_agent_loop(app: &mut App, message: String) {
1337    if app.session.is_none() {
1338        return;
1339    }
1340
1341    app.is_streaming = true;
1342    app.working.start();
1343    app.footer.borrow_mut().set_streaming(true);
1344
1345    let thinking = map_thinking_level(app.thinking_level.as_deref());
1346
1347    // Build or reuse agent. On the first turn the session has no messages;
1348    // on subsequent turns the reused agent already has messages restored
1349    // by agent.finish() — no need to sync from session here.
1350    let msgs = app
1351        .session
1352        .as_ref()
1353        .map(|s| s.session().build_session_context().messages)
1354        .unwrap_or_default();
1355
1356    let agent: &mut yoagent::agent::Agent = match &mut app.agent {
1357        Some(existing) => {
1358            // Reuse existing agent — messages are already correct from
1359            // agent.finish(). Compaction sync is handled separately by
1360            // handle_auto_compact / handle_compact_command.
1361            existing
1362        }
1363        None => {
1364            app.agent = Some(build_fresh_agent(
1365                &app.model,
1366                &app.api_key,
1367                &app.system_prompt,
1368                thinking,
1369                msgs,
1370                &app.extensions,
1371            ));
1372            // SAFETY: we just set app.agent to Some(...)
1373            app.agent.as_mut().unwrap()
1374        }
1375    };
1376
1377    // Record model/thinking changes in the session
1378    if let Some(ref mut session) = app.session {
1379        session.on_model_change("opencode-go", &app.model);
1380        session.on_thinking_level_change(app.thinking_level.as_deref().unwrap_or("off"));
1381    }
1382
1383    // Start the turn: agent.prompt() spawns the loop internally, keeps the
1384    // Agent in scope, and returns a receiver for streaming events.
1385    let mut rx = agent.prompt(message).await;
1386
1387    // Forward events from the agent's receiver to the UI channel.
1388    // This runs concurrently while the agent loop processes the turn.
1389    let tx = app.event_tx.clone();
1390    let handle = tokio::spawn(async move {
1391        while let Some(event) = rx.recv().await {
1392            if tx.send(event).is_err() {
1393                break;
1394            }
1395        }
1396    });
1397    app.forward_handle = Some(handle);
1398}
1399
1400/// Handle manual compaction asynchronously.
1401/// Called from the main loop when pending_compact is set.
1402async fn handle_compact_command(app: &mut App, custom_instructions: Option<String>) {
1403    if app.session.is_none() {
1404        chat_add(
1405            app,
1406            std::boxed::Box::new(InfoMessageComponent::new(
1407                "No active session to compact".to_string(),
1408            )),
1409        );
1410        return;
1411    }
1412
1413    let agent_session = app.session.as_mut().unwrap();
1414
1415    app.working.start();
1416
1417    match agent_session
1418        .run_manual_compact(custom_instructions.as_deref())
1419        .await
1420    {
1421        Ok(_summary) => {
1422            app.working.stop();
1423            app.status_text = None;
1424            app.rebuild_from_session_context();
1425            show_status(app, "Compaction completed".to_string());
1426        }
1427        Err(e) => {
1428            app.working.stop();
1429            app.status_text = None;
1430            chat_add(
1431                app,
1432                std::boxed::Box::new(InfoMessageComponent::new(format!(
1433                    "Compaction failed: {}",
1434                    e
1435                ))),
1436            );
1437        }
1438    }
1439}
1440
1441/// Pi-compatible: auto-compaction check after agent ends.
1442/// Calls `check_auto_compact()` on the session. If compaction was performed,
1443/// rebuilds the chat from the updated session context and updates agent state.
1444async fn handle_auto_compact(app: &mut App) {
1445    if app.session.is_none() {
1446        return;
1447    }
1448
1449    let agent_session = app.session.as_mut().unwrap();
1450
1451    match agent_session.check_auto_compact().await {
1452        Ok(true) => {
1453            app.rebuild_from_session_context();
1454            // Refresh footer stats (token counts may have changed)
1455            if let Some(ref s) = app.session {
1456                app.footer.borrow_mut().refresh_from_session(s.session());
1457            }
1458            app.status_text = Some("Auto-compaction completed".to_string());
1459        }
1460        Ok(false) => {
1461            // No compaction needed — nothing to do
1462        }
1463        Err(e) => {
1464            eprintln!("Warning: Auto-compaction failed: {}", e);
1465            app.status_text = Some(format!("Auto-compaction skipped: {}", e));
1466        }
1467    }
1468}
1469
1470/// Handle keyboard input for the session picker.
1471fn handle_session_picker_input(app: &mut App, key: &crossterm::event::KeyEvent) {
1472    use crossterm::event::KeyCode;
1473
1474    let Some(ref mut picker) = app.session_picker else {
1475        return;
1476    };
1477
1478    match key.code {
1479        KeyCode::Esc => {
1480            app.session_picker = None;
1481            app.status_text = None;
1482        }
1483        KeyCode::Enter => {
1484            if let Some(path) = picker.selected_path() {
1485                let path = path.clone();
1486                app.session_picker = None;
1487                app.status_text = None;
1488                // Delegate to the shared SessionSwitched handler
1489                app.pending_command_result = Some(CommandResult::SessionSwitched { path });
1490            }
1491        }
1492        KeyCode::Up => {
1493            picker.select_prev();
1494        }
1495        KeyCode::Down => {
1496            picker.select_next();
1497        }
1498        KeyCode::Char('/') => {
1499            picker.set_filter("");
1500        }
1501        KeyCode::Char(c) => {
1502            let mut filter = picker.filter().to_string();
1503            filter.push(c);
1504            picker.set_filter(&filter);
1505        }
1506        KeyCode::Backspace => {
1507            let mut filter = picker.filter().to_string();
1508            filter.pop();
1509            picker.set_filter(&filter);
1510        }
1511        _ => {}
1512    }
1513}
1514
1515/// Handle slash commands by dispatching through extension command handlers.
1516/// For commands that need TUI access (overlays), the result is stored in
1517/// `pending_command_result` and consumed in the main loop where TUI is available.
1518/// Simple results (Info, Quit, etc.) are handled immediately.
1519fn handle_slash_command(app: &mut App, input: &str) {
1520    let (cmd_name, args) = match input.split_once(' ') {
1521        Some((cmd, rest)) => (cmd.trim_start_matches('/'), rest),
1522        None => (input.trim_start_matches('/'), ""),
1523    };
1524
1525    // Find the command handler first (before mutable borrow on app)
1526    for ext in app.extensions.iter() {
1527        for cmd in ext.commands() {
1528            if cmd.name == cmd_name {
1529                // Execute the handler here while we have immutably borrowed app,
1530                // then use the result after dropping the borrow.
1531                let result = cmd.handler.execute(args);
1532                match result {
1533                    Ok(result) => {
1534                        // Drop the iterator borrow before mutating app
1535                        drop((ext, cmd));
1536                        handle_command_result(app, result);
1537                        return;
1538                    }
1539                    Err(e) => {
1540                        drop((ext, cmd));
1541                        chat_add(
1542                            app,
1543                            std::boxed::Box::new(InfoMessageComponent::new(format!(
1544                                "Error executing /{}: {}",
1545                                cmd_name, e
1546                            ))),
1547                        );
1548                        return;
1549                    }
1550                }
1551            }
1552        }
1553    }
1554
1555    // Unknown command
1556    let available: Vec<&str> = app.commands.iter().map(|(n, _)| n.as_str()).collect();
1557    app.status_text = Some(format!(
1558        "Unknown command: /{}. Available: {}",
1559        cmd_name,
1560        available.join(", ")
1561    ));
1562}
1563
1564/// Handle a CommandResult from a slash command.
1565/// Simple results are applied immediately; overlay-requiring ones
1566/// are stored in `pending_command_result` for the main loop.
1567fn handle_command_result(app: &mut App, result: CommandResult) {
1568    match result {
1569        CommandResult::Info(msg) => {
1570            chat_add(
1571                app,
1572                std::boxed::Box::new(InfoMessageComponent::new(msg.clone())),
1573            );
1574        }
1575        CommandResult::Quit => {
1576            app.should_quit = true;
1577        }
1578        CommandResult::ModelChanged(model) => {
1579            app.model = model.clone();
1580            app.footer.borrow_mut().set_model(&model);
1581            app.status_text = Some(format!("Model: {}", model));
1582        }
1583        CommandResult::ShowHelp => {
1584            // Needs TUI overlay - defer
1585            app.pending_command_result = Some(result);
1586        }
1587        CommandResult::Reloaded => {
1588            // Actually reload settings from disk (pi-compatible)
1589            if let Err(e) = app.settings.reload(&app.cwd) {
1590                app.status_text = Some(format!("Failed to reload settings: {}", e));
1591            } else {
1592                // Apply reloaded settings to runtime state
1593                if let Some(level) = app.settings.default_thinking_level.clone() {
1594                    app.thinking_level = Some(level.clone());
1595                    app.footer
1596                        .borrow_mut()
1597                        .set_thinking_level(Some(level.clone()));
1598                    // yoagent hardcodes ThinkingLevel::High
1599                }
1600                app.hide_thinking = app.settings.hide_thinking.unwrap_or(true);
1601                // Propagate to all chat container components
1602                {
1603                    let mut chat = app.chat_container.borrow_mut();
1604                    for child in chat.children_mut().iter_mut() {
1605                        child.set_hide_thinking(app.hide_thinking);
1606                    }
1607                }
1608                // Update streaming component if it exists
1609                if let Some(weak) = app.streaming_component.as_ref().and_then(|w| w.upgrade()) {
1610                    weak.borrow_mut().set_hide_thinking(app.hide_thinking);
1611                }
1612                app.editor.borrow_mut().update_border_color(
1613                    app.thinking_level.as_deref(),
1614                    &app.theme as &dyn crate::tui::Theme,
1615                );
1616                chat_add(
1617                    app,
1618                    std::boxed::Box::new(InfoMessageComponent::new(
1619                        "Settings, extensions, and keybindings reloaded.".to_string(),
1620                    )),
1621                );
1622            }
1623        }
1624        CommandResult::NewSession => {
1625            // Matching pi's handleClearCommand:
1626            //   1. Stop loading animation
1627            //   2. Clear status container
1628            //   3. runtimeHost.newSession() -> session.new_session()
1629            //   4. renderCurrentSessionState() -> clear everything
1630            //   5. Add "✓ New session started" with accent color + spacer
1631
1632            // Stop working indicator (matching pi's loadingAnimation.stop())
1633            app.working.stop();
1634
1635            // Clear status section (matching pi's statusContainer.clear())
1636            app.status_text = None;
1637
1638            // Create a new session via AgentSession (new ID, new file, resets tracked state)
1639            if let Some(ref mut agent_session) = app.session {
1640                agent_session.new_session();
1641            }
1642
1643            // Clear everything (matching pi's renderCurrentSessionState)
1644            app.agent = None;
1645            app.clear_session_state();
1646
1647            // Refresh footer cached stats from the now-empty session
1648            if let Some(ref s) = app.session {
1649                app.footer.borrow_mut().refresh_from_session(s.session());
1650            }
1651
1652            // Add "✓ New session started" with accent color, matching pi's
1653            // `new Text(theme.fg("accent", "✓ New session started"), 1, 1)`
1654            let styled = app.theme.fg("accent", "✓ New session started");
1655            chat_add(app, std::boxed::Box::new(Text::new(styled, 1, 1, None)));
1656        }
1657        CommandResult::SessionSwitched { path } => {
1658            let new_session = crate::agent::AgentSession::open(&path, None, Some(&app.cwd));
1659            app.switch_to_session(new_session);
1660            app.status_text = Some(format!("Switched to session: {}", path.display()));
1661        }
1662        CommandResult::SessionInfo {
1663            session_id,
1664            file_path,
1665            name,
1666            message_count: _,
1667            user_messages: _,
1668            assistant_messages: _,
1669            tool_calls: _,
1670            tool_results: _,
1671            total_tokens: _,
1672            input_tokens: _,
1673            output_tokens: _,
1674            cache_read_tokens: _,
1675            cache_write_tokens: _,
1676            cost: _,
1677        } => {
1678            // Compute live stats from session (authoritative store)
1679            let msgs = app
1680                .session
1681                .as_ref()
1682                .map(|s| s.session().build_session_context().messages)
1683                .unwrap_or_default();
1684
1685            let name_display = name
1686                .or_else(|| {
1687                    app.session
1688                        .as_ref()
1689                        .and_then(|s| s.session().session_name())
1690                })
1691                .unwrap_or_else(|| "unnamed".to_string());
1692            let file_display = file_path
1693                .as_ref()
1694                .map(|p| p.display().to_string())
1695                .unwrap_or_else(|| "in-memory".to_string());
1696            let sid = if session_id.is_empty() {
1697                app.session
1698                    .as_ref()
1699                    .map(|s| s.session().session_id())
1700                    .unwrap_or_default()
1701            } else {
1702                session_id
1703            };
1704
1705            let user_messages = msgs
1706                .iter()
1707                .filter(|m| crate::agent::types::message_is_user(m))
1708                .count();
1709            let assistant_messages = msgs
1710                .iter()
1711                .filter(|m| crate::agent::types::message_is_assistant(m))
1712                .count();
1713            let tool_results = msgs
1714                .iter()
1715                .filter(|m| crate::agent::types::message_is_tool_result(m))
1716                .count();
1717            let tool_calls: usize = msgs
1718                .iter()
1719                .map(crate::agent::types::message_tool_call_count)
1720                .sum();
1721            let total_messages = user_messages + assistant_messages + tool_results;
1722
1723            let mut input_tokens: u64 = 0;
1724            let mut output_tokens: u64 = 0;
1725            let mut cache_read_tokens: u64 = 0;
1726            let cost: f64 = 0.0;
1727            for msg in &msgs {
1728                if let Some(usage) = crate::agent::types::message_usage(msg) {
1729                    input_tokens += usage.input;
1730                    output_tokens += usage.output;
1731                    cache_read_tokens += usage.cache_read;
1732                }
1733            }
1734            let total_tokens = input_tokens + output_tokens + cache_read_tokens;
1735
1736            // Build info display matching pi's handleSessionCommand
1737            let mut info = format!(
1738                "Session Info\n\n\
1739                 Name: {name_display}\n\
1740                 File: {file_display}\n\
1741                 ID: {sid}\n\
1742                 \n\
1743                 Messages\n\
1744                 User: {user_messages}\n\
1745                 Assistant: {assistant_messages}\n\
1746                 Tool Calls: {tool_calls}\n\
1747                 Tool Results: {tool_results}\n\
1748                 Total: {total_messages}\n\
1749                 \n\
1750                 Tokens\n\
1751                 Input: {}\n\
1752                 Output: {}\n\
1753                 Total: {}",
1754                format_number(input_tokens),
1755                format_number(output_tokens),
1756                format_number(total_tokens),
1757            );
1758            if cache_read_tokens > 0 {
1759                info += &format!("\nCache Read: {}", format_number(cache_read_tokens));
1760            }
1761            if cost > 0.0 {
1762                info += &format!("\n\nCost\nTotal: {:.4}", cost);
1763            }
1764
1765            // Parent session (fork chain)
1766            if let Some(ref asession) = app.session
1767                && let Some(file_path) = asession.session().session_file().as_ref()
1768                && let Some(h) = crate::agent::session::read_session_header(file_path)
1769                && let Some(ref parent) = h.parent_session
1770            {
1771                info += &format!("\n\nParent: {}", parent);
1772            }
1773
1774            chat_add(
1775                app,
1776                std::boxed::Box::new(InfoMessageComponent::new(info.clone())),
1777            );
1778        }
1779        CommandResult::OpenSessionSelector => {
1780            // Load and display available sessions
1781            use crate::agent::SessionRepo;
1782            let repo = crate::agent::DefaultSessionRepo::new();
1783            let sessions = repo.list_all(None);
1784
1785            if sessions.is_empty() {
1786                let msg = "No sessions found.".to_string();
1787                chat_add(
1788                    app,
1789                    std::boxed::Box::new(InfoMessageComponent::new(msg.clone())),
1790                );
1791            } else {
1792                let mut info = format!("Available Sessions ({} total)\n\n", sessions.len());
1793                for (i, s) in sessions.iter().take(20).enumerate() {
1794                    let name = s.name.as_deref().unwrap_or("unnamed");
1795                    let cwd_short = s.cwd.rsplit('/').next().unwrap_or(&s.cwd);
1796                    info += &format!(
1797                        "{}. {}  [{}]  {} msgs\n   {}\n\n",
1798                        i + 1,
1799                        name,
1800                        fmt_time_short(&s.created),
1801                        s.message_count,
1802                        cwd_short,
1803                    );
1804                }
1805                if sessions.len() > 20 {
1806                    info += &format!("... and {} more sessions\n", sessions.len() - 20);
1807                }
1808                info += "Use /resume to open the interactive picker";
1809
1810                chat_add(
1811                    app,
1812                    std::boxed::Box::new(InfoMessageComponent::new(info.clone())),
1813                );
1814            }
1815        }
1816        CommandResult::SessionNamed { name } => {
1817            app.status_text = Some(format!("Session name: {}", name));
1818
1819            // Persist name in session
1820            if let Some(ref mut s) = app.session {
1821                s.session_mut().append_session_info(&name);
1822            }
1823
1824            // Update session info and footer (refresh_from_session picks up the new name)
1825            app.update_session_info();
1826            if let Some(ref s) = app.session {
1827                app.footer.borrow_mut().refresh_from_session(s.session());
1828            }
1829        }
1830        CommandResult::OpenSettings => {
1831            // Needs TUI overlay - defer
1832            app.pending_command_result = Some(result);
1833        }
1834        CommandResult::ScopedModels => {
1835            // Needs TUI overlay - defer
1836            app.pending_command_result = Some(result);
1837        }
1838        CommandResult::ExportSession { path } => {
1839            let msg = if let Some(p) = path {
1840                format!("Export session to {} - not yet implemented.", p)
1841            } else {
1842                "Export session - not yet implemented (defaults to HTML).".to_string()
1843            };
1844            chat_add(
1845                app,
1846                std::boxed::Box::new(InfoMessageComponent::new(msg.clone())),
1847            );
1848        }
1849        CommandResult::ImportSession { path } => {
1850            let msg = format!("Import session from {} - not yet implemented.", path);
1851            chat_add(
1852                app,
1853                std::boxed::Box::new(InfoMessageComponent::new(msg.clone())),
1854            );
1855        }
1856        CommandResult::ShareSession => {
1857            let msg = "Share session - not yet implemented.".to_string();
1858            chat_add(
1859                app,
1860                std::boxed::Box::new(InfoMessageComponent::new(msg.clone())),
1861            );
1862        }
1863        CommandResult::CopyLastMessage => {
1864            let msg = "Copy last agent message to clipboard - not yet implemented.".to_string();
1865            chat_add(
1866                app,
1867                std::boxed::Box::new(InfoMessageComponent::new(msg.clone())),
1868            );
1869        }
1870        CommandResult::ShowChangelog => {
1871            let msg = "Changelog - not yet implemented.".to_string();
1872            chat_add(
1873                app,
1874                std::boxed::Box::new(InfoMessageComponent::new(msg.clone())),
1875            );
1876        }
1877        CommandResult::ForkSession { message_id } => {
1878            // Clone the session info before modifying app.session
1879            let source_path = app
1880                .session
1881                .as_ref()
1882                .and_then(|s| s.session().session_file());
1883            let session_dir = app.session.as_ref().map(|s| s.session_dir().to_path_buf());
1884            let cwd = app.cwd.clone();
1885
1886            match (source_path, session_dir) {
1887                (Some(ref source), Some(ref target_dir)) => {
1888                    match crate::agent::session::fork_session(
1889                        source,
1890                        target_dir,
1891                        message_id.as_deref(),
1892                        None,
1893                    ) {
1894                        Ok(new_id) => {
1895                            // Find the new session file
1896                            let dir_entries = std::fs::read_dir(target_dir).ok();
1897                            let new_path = dir_entries.and_then(|entries| {
1898                                entries
1899                                    .flatten()
1900                                    .find(|e| {
1901                                        let filename = e.file_name();
1902                                        filename.to_string_lossy().contains(&new_id)
1903                                    })
1904                                    .map(|e| e.path())
1905                            });
1906
1907                            match new_path {
1908                                Some(ref path) => {
1909                                    // Open the new session and replace the current one
1910                                    let new_session =
1911                                        crate::agent::AgentSession::open(path, None, Some(&cwd));
1912                                    app.switch_to_session(new_session);
1913
1914                                    let styled = app.theme.fg(
1915                                        "accent",
1916                                        &format!("✓ Forked session: {}", path.display()),
1917                                    );
1918                                    chat_add(
1919                                        app,
1920                                        std::boxed::Box::new(Text::new(styled, 1, 1, None)),
1921                                    );
1922                                }
1923                                None => {
1924                                    let msg =
1925                                        format!("Fork created but new file not found: {}", new_id);
1926                                    chat_add(
1927                                        app,
1928                                        std::boxed::Box::new(InfoMessageComponent::new(msg)),
1929                                    );
1930                                }
1931                            }
1932                        }
1933                        Err(e) => {
1934                            let msg = format!("Fork failed: {}", e);
1935                            chat_add(
1936                                app,
1937                                std::boxed::Box::new(InfoMessageComponent::new(msg.clone())),
1938                            );
1939                        }
1940                    }
1941                }
1942                _ => {
1943                    let msg = "No active session to fork".to_string();
1944                    chat_add(
1945                        app,
1946                        std::boxed::Box::new(InfoMessageComponent::new(msg.clone())),
1947                    );
1948                }
1949            }
1950        }
1951        CommandResult::CloneSession => {
1952            let msg = "Clone session - not yet implemented.".to_string();
1953            chat_add(
1954                app,
1955                std::boxed::Box::new(InfoMessageComponent::new(msg.clone())),
1956            );
1957        }
1958        CommandResult::SessionTree => {
1959            let msg = "Session tree - not yet implemented.".to_string();
1960            chat_add(
1961                app,
1962                std::boxed::Box::new(InfoMessageComponent::new(msg.clone())),
1963            );
1964        }
1965        CommandResult::TrustDecision { decision } => {
1966            let msg = format!("Trust decision '{}' saved.", decision);
1967            chat_add(
1968                app,
1969                std::boxed::Box::new(InfoMessageComponent::new(msg.clone())),
1970            );
1971        }
1972        CommandResult::Login { provider: _ } => {
1973            // Needs TUI overlay - defer
1974            app.pending_command_result = Some(result);
1975        }
1976        CommandResult::Logout { provider } => {
1977            let prov = provider.as_deref().unwrap_or("all providers");
1978            let msg = format!("Logged out from {} - not yet implemented.", prov);
1979            chat_add(
1980                app,
1981                std::boxed::Box::new(InfoMessageComponent::new(msg.clone())),
1982            );
1983        }
1984        CommandResult::CompactSession(custom_instructions) => {
1985            // If streaming, interrupt first
1986            if app.is_streaming {
1987                interrupt_streaming(app);
1988            }
1989            app.pending_compact = Some(custom_instructions);
1990        }
1991    }
1992}
1993
1994/// Look up a tool renderer by name from extensions (bundled in ToolDefinition.renderer).
1995fn find_tool_renderer(
1996    extensions: &[Box<dyn crate::agent::extension::Extension>],
1997    name: &str,
1998) -> Option<Arc<dyn ToolRenderer>> {
1999    for ext in extensions {
2000        for tool in ext.tools() {
2001            if tool.name() == name {
2002                return tool.renderer;
2003            }
2004        }
2005    }
2006    None
2007}
2008
2009/// Handle ! and !! bang commands.
2010/// Renders via ToolExecComponent with the bash renderer (same visual treatment
2011/// as LLM-invoked bash tool calls, eliminating the separate BashExecution split).
2012fn handle_bang_command(app: &mut App, command: String) {
2013    let cwd = app.cwd.clone();
2014    let tx = app.event_tx.clone();
2015    use yoagent::types::{AgentEvent as YoEvent, Content as YoContent, ToolResult as YoResult};
2016
2017    let renderer = find_tool_renderer(&app.extensions, "bash");
2018    let mut tool = crate::agent::ui::components::ToolExecComponent::new(
2019        "bash",
2020        renderer,
2021        serde_json::json!({"command": command}),
2022        app.cwd.to_string_lossy().to_string(),
2023        "__bang__".to_string(),
2024    );
2025    tool.set_started_at(std::time::Instant::now());
2026    let (invalidate_tx, invalidate_rx) =
2027        crate::agent::ui::components::ToolExecComponent::make_invalidation_channel();
2028    app.invalidate_rxs.push(invalidate_rx);
2029    tool.set_invalidate_tx(invalidate_tx);
2030    tool.set_expanded(app.tools_expanded);
2031    let tool = Rc::new(RefCell::new(tool));
2032    app.pending_tools
2033        .insert("__bang__".to_string(), Rc::downgrade(&tool));
2034    chat_add(
2035        app,
2036        std::boxed::Box::new(crate::agent::ui::components::RcToolExec(tool)),
2037    );
2038    app.is_streaming = true;
2039    app.working.start();
2040    app.footer.borrow_mut().set_streaming(true);
2041    app.pending_tool_executions += 1;
2042
2043    let handle = tokio::spawn(async move {
2044        struct Guard<'a> {
2045            tx: &'a mpsc::UnboundedSender<yoagent::types::AgentEvent>,
2046            sent: bool,
2047        }
2048        impl Drop for Guard<'_> {
2049            fn drop(&mut self) {
2050                if !self.sent {
2051                    let _ = self.tx.send(YoEvent::AgentEnd { messages: vec![] });
2052                }
2053            }
2054        }
2055        let mut guard = Guard {
2056            tx: &tx,
2057            sent: false,
2058        };
2059
2060        let mut child = match tokio::process::Command::new("sh")
2061            .arg("-c")
2062            .arg(&command)
2063            .current_dir(&cwd)
2064            .stdout(std::process::Stdio::piped())
2065            .stderr(std::process::Stdio::piped())
2066            .spawn()
2067        {
2068            Ok(c) => c,
2069            Err(e) => {
2070                let _ = tx.send(YoEvent::ToolExecutionEnd {
2071                    tool_call_id: "__bang__".to_string(),
2072                    tool_name: "bash".into(),
2073                    result: YoResult {
2074                        content: vec![YoContent::Text {
2075                            text: format!("Failed to execute: {:#}", e),
2076                        }],
2077                        details: serde_json::Value::Null,
2078                    },
2079                    is_error: true,
2080                });
2081                guard.sent = true;
2082                let _ = tx.send(YoEvent::AgentEnd { messages: vec![] });
2083                return;
2084            }
2085        };
2086
2087        let mut all_output = String::new();
2088        // Stream stdout and stderr concurrently using tokio async reads
2089        use tokio::io::AsyncReadExt;
2090        let mut stdio = child.stdout.take().unwrap();
2091        let mut stderr = child.stderr.take().unwrap();
2092        let mut buf1 = [0u8; 4096];
2093        let mut buf2 = [0u8; 4096];
2094        let mut stdout_done = false;
2095        let mut stderr_done = false;
2096
2097        loop {
2098            tokio::select! {
2099                result = stdio.read(&mut buf1), if !stdout_done => {
2100                    match result {
2101                        Ok(0) => stdout_done = true,
2102                        Ok(n) => {
2103                            if let Ok(text) = std::str::from_utf8(&buf1[..n]) {
2104                                all_output.push_str(text);
2105                                let _ = tx.send(YoEvent::ProgressMessage {
2106                                    tool_call_id: "__bang__".to_string(),
2107                                    tool_name: "bash".into(),
2108                                    text: text.to_string(),
2109                                });
2110                            }
2111                        }
2112                        Err(_) => stdout_done = true,
2113                    }
2114                }
2115                result = stderr.read(&mut buf2), if !stderr_done => {
2116                    match result {
2117                        Ok(0) => stderr_done = true,
2118                        Ok(n) => {
2119                            if let Ok(text) = std::str::from_utf8(&buf2[..n]) {
2120                                all_output.push_str(text);
2121                                let _ = tx.send(YoEvent::ProgressMessage {
2122                                    tool_call_id: "__bang__".to_string(),
2123                                    tool_name: "bash".into(),
2124                                    text: text.to_string(),
2125                                });
2126                            }
2127                        }
2128                        Err(_) => stderr_done = true,
2129                    }
2130                }
2131            }
2132            if stdout_done && stderr_done {
2133                break;
2134            }
2135        }
2136
2137        // Wait for process to finish
2138        let status = child.wait().await;
2139        let is_error = match &status {
2140            Ok(s) => !s.success(),
2141            Err(_) => true,
2142        };
2143        let result = if all_output.trim().is_empty() {
2144            "(no output)".to_string()
2145        } else {
2146            all_output.trim().to_string()
2147        };
2148
2149        let _ = tx.send(YoEvent::ToolExecutionEnd {
2150            tool_call_id: "__bang__".to_string(),
2151            tool_name: "bash".into(),
2152            result: YoResult {
2153                content: vec![YoContent::Text { text: result }],
2154                details: serde_json::Value::Null,
2155            },
2156            is_error,
2157        });
2158        guard.sent = true;
2159        let _ = tx.send(YoEvent::AgentEnd { messages: vec![] });
2160    });
2161    app.bash_abort_handle = Some(handle.abort_handle());
2162}
2163
2164/// Rebuild the chat container from a slice of AgentMessages (pi's renderSessionContext).
2165/// Clears the container and re-adds all message components with spacers between them.
2166/// Adjacent tool calls and tool results are paired into single ToolExecComponent.
2167pub fn rebuild_chat_from_messages(
2168    chat: &mut crate::tui::Container,
2169    messages: &[yoagent::types::AgentMessage],
2170    cwd: &str,
2171    hide_thinking: bool,
2172    _collapse_tool_output: bool,
2173    extensions: &[Box<dyn crate::agent::extension::Extension>],
2174) {
2175    chat.clear();
2176    use std::collections::HashMap;
2177    let mut pending_tool_components: HashMap<
2178        String,
2179        Rc<RefCell<crate::agent::ui::components::ToolExecComponent>>,
2180    > = HashMap::new();
2181
2182    for msg in messages {
2183        if crate::agent::types::message_is_user(msg) {
2184            let text = crate::agent::types::message_text(msg);
2185            if text.is_empty() {
2186                continue;
2187            }
2188            if !chat.children().is_empty() {
2189                chat.add_child(std::boxed::Box::new(Spacer::new(1)));
2190            }
2191            chat.add_child(std::boxed::Box::new(
2192                crate::agent::ui::components::UserMessageComponent::new(text),
2193            ));
2194        } else if crate::agent::types::message_is_assistant(msg) {
2195            let text = crate::agent::types::message_text(msg);
2196            if let yoagent::types::AgentMessage::Llm(yoagent::types::Message::Assistant {
2197                content,
2198                ..
2199            }) = msg
2200            {
2201                let tcs = crate::agent::types::content_tool_calls(content);
2202                if !tcs.is_empty() {
2203                    // Assistant with tool calls — render text first
2204                    if !text.trim().is_empty() {
2205                        if !chat.children().is_empty() {
2206                            chat.add_child(std::boxed::Box::new(Spacer::new(1)));
2207                        }
2208                        let mut asst =
2209                            crate::agent::ui::components::AssistantMessageComponent::new(&text);
2210                        if hide_thinking {
2211                            asst.set_hide_thinking(true);
2212                        }
2213                        chat.add_child(std::boxed::Box::new(asst));
2214                    }
2215                    // Create ToolExecComponent for each tool call
2216                    for (id, name, args) in &tcs {
2217                        let renderer = find_tool_renderer(extensions, name);
2218                        let tool = crate::agent::ui::components::ToolExecComponent::new(
2219                            name,
2220                            renderer,
2221                            args.clone(),
2222                            cwd.to_string(),
2223                            id.clone(),
2224                        );
2225                        let tool = Rc::new(RefCell::new(tool));
2226                        chat.add_child(std::boxed::Box::new(
2227                            crate::agent::ui::components::RcToolExec(tool.clone()),
2228                        ));
2229                        pending_tool_components.insert(id.clone(), tool);
2230                    }
2231                } else if !text.trim().is_empty() {
2232                    // Plain text assistant
2233                    if !chat.children().is_empty() {
2234                        chat.add_child(std::boxed::Box::new(Spacer::new(1)));
2235                    }
2236                    let mut asst =
2237                        crate::agent::ui::components::AssistantMessageComponent::new(&text);
2238                    if hide_thinking {
2239                        asst.set_hide_thinking(true);
2240                    }
2241                    chat.add_child(std::boxed::Box::new(asst));
2242                }
2243            }
2244        } else if crate::agent::types::message_is_tool_result(msg) {
2245            let is_error = crate::agent::types::message_is_error(msg);
2246            let text = crate::agent::types::message_text(msg);
2247            if let Some(tc_id) = crate::agent::types::message_tool_call_id(msg)
2248                && let Some(tool) = pending_tool_components.remove(tc_id)
2249            {
2250                let clean = text
2251                    .strip_prefix("✓ ")
2252                    .or_else(|| text.strip_prefix("✗ "))
2253                    .unwrap_or(&text);
2254                let mut tool = tool.borrow_mut();
2255                tool.set_result_with_details(clean, is_error, None);
2256            }
2257        } else if crate::agent::types::message_is_extension(msg) {
2258            // Extension messages (info, error, system_stop) rendered as info text.
2259            if let Some(text) = crate::agent::types::message_extension_text(msg) {
2260                if !chat.children().is_empty() {
2261                    chat.add_child(std::boxed::Box::new(Spacer::new(1)));
2262                }
2263                chat.add_child(std::boxed::Box::new(InfoMessageComponent::new(text)));
2264            }
2265        }
2266    }
2267}
2268
2269/// Add a Component to chat_container with a spacer before it if chat_container is not empty.
2270/// Mirrors pi's `addMessageToChat()` which adds `new Spacer(1)` before each message
2271/// when `this.chatContainer.children.length > 0`.
2272pub fn chat_add(app: &mut App, component: std::boxed::Box<dyn Component>) {
2273    let mut chat = app.chat_container.borrow_mut();
2274    if !chat.children().is_empty() {
2275        chat.add_child(std::boxed::Box::new(Spacer::new(1)));
2276    }
2277    chat.add_child(component);
2278}
2279
2280/// Show a status message in the chat (pi-style `showStatus`).
2281///
2282/// If the last two children of `chat_container` are from a previous status
2283/// (spacer + InfoMessageComponent), they are replaced in-place rather than
2284/// appending new entries. This prevents multiple consecutive status messages
2285/// from accumulating at the end of the chat session.
2286fn show_status(app: &mut App, message: String) {
2287    let mut chat = app.chat_container.borrow_mut();
2288    // Check if previous status children are still the last in the container
2289    if let Some(prev_len) = app.last_status_len
2290        && chat.len() == prev_len
2291        && prev_len >= 2
2292    {
2293        chat.pop_child(); // info message
2294        chat.pop_child(); // spacer
2295    }
2296    app.last_status_len = None;
2297    drop(chat);
2298
2299    // Add the new status
2300    let mut chat = app.chat_container.borrow_mut();
2301    if !chat.children().is_empty() {
2302        chat.add_child(std::boxed::Box::new(Spacer::new(1)));
2303    }
2304    chat.add_child(std::boxed::Box::new(InfoMessageComponent::new(message)));
2305    app.last_status_len = Some(chat.len());
2306}
2307
2308/// Handle agent events from the channel.
2309///
2310/// Delegates persistence to `AgentSession::on_agent_event()` (single source of truth)
2311/// and only handles display/UI logic here. This mirrors pi's single _handleAgentEvent
2312/// that all modes share — the mode-agnostic persistence lives on AgentSession, and each
2313/// mode adds display on top.
2314fn handle_agent_event(app: &mut App, event: yoagent::types::AgentEvent) {
2315    // ── Persistence: delegate to the shared handler (single source of truth) ──
2316    // Match on &event while event is still owned, to avoid consuming it.
2317    match &event {
2318        E::MessageEnd { message } => {
2319            // Pi-compatible: reset overflow recovery when a user message arrives
2320            // (matches pi's _overflowRecoveryAttempted reset in message_start for user).
2321            if crate::agent::types::message_is_user(message)
2322                && let Some(ref mut s) = app.session
2323            {
2324                s.reset_overflow_recovery();
2325            }
2326            // Special cases: persist as extension (excluded from LLM context).
2327            // on_agent_event would persist them as regular LLM messages, so skip.
2328            if crate::agent::types::message_error(message).is_some()
2329                || crate::agent::types::message_is_system_stop(message)
2330            {
2331                // Handled inline below with display.
2332            } else if let Some(ref mut s) = app.session {
2333                s.on_agent_event(&event);
2334            }
2335        }
2336        E::ToolExecutionEnd { tool_call_id, .. } => {
2337            // Skip bang commands (user-initiated, not agent-invoked).
2338            if tool_call_id != "__bang__"
2339                && let Some(ref mut s) = app.session
2340            {
2341                s.on_agent_event(&event);
2342            }
2343        }
2344        E::AgentEnd { .. } => {
2345            if let Some(ref mut s) = app.session {
2346                s.on_agent_event(&event);
2347            }
2348        }
2349        _ => {}
2350    }
2351
2352    // ── Display logic (consumes owned event) ──
2353    use yoagent::types::AgentEvent as E;
2354    match event {
2355        E::AgentStart => {
2356            app.is_streaming = true;
2357            app.working.start();
2358            app.refresh_git_branch();
2359        }
2360        E::TurnStart => {}
2361        E::MessageStart { message } => {
2362            // Add user messages to chat when the agent loop processes them.
2363            // Covers both the initial prompt (non-streaming) and
2364            // steered/follow-up messages queued during streaming.
2365            if crate::agent::types::message_is_user(&message) {
2366                let text = crate::agent::types::message_text(&message);
2367                if !text.is_empty() {
2368                    chat_add(
2369                        app,
2370                        std::boxed::Box::new(
2371                            crate::agent::ui::components::UserMessageComponent::new(&text),
2372                        ),
2373                    );
2374                }
2375            }
2376        }
2377        E::MessageUpdate { delta, .. } => {
2378            use yoagent::types::StreamDelta;
2379            match delta {
2380                StreamDelta::Text { delta } => {
2381                    if let Some(weak) = app.streaming_component.as_ref().and_then(|w| w.upgrade()) {
2382                        weak.borrow_mut().append_text(&delta);
2383                    } else {
2384                        use crate::tui::components::rc_ref_cell_component::RcRefCellComponent;
2385                        let comp = Rc::new(RefCell::new(
2386                            crate::agent::ui::components::AssistantMessageComponent::new(&delta),
2387                        ));
2388                        if app.hide_thinking {
2389                            comp.borrow_mut().set_hide_thinking(true);
2390                        }
2391                        app.streaming_component = Some(Rc::downgrade(&comp));
2392                        app.chat_container
2393                            .borrow_mut()
2394                            .add_child(std::boxed::Box::new(RcRefCellComponent(comp)));
2395                    }
2396                }
2397                StreamDelta::Thinking { delta } => {
2398                    if let Some(weak) = app.streaming_component.as_ref().and_then(|w| w.upgrade()) {
2399                        weak.borrow_mut()
2400                            .add_thinking(&delta, app.thinking_level.clone());
2401                    } else {
2402                        use crate::tui::components::rc_ref_cell_component::RcRefCellComponent;
2403                        let mut comp =
2404                            crate::agent::ui::components::AssistantMessageComponent::new("");
2405                        comp.add_thinking(&delta, app.thinking_level.clone());
2406                        if app.hide_thinking {
2407                            comp.set_hide_thinking(true);
2408                        }
2409                        let comp = Rc::new(RefCell::new(comp));
2410                        app.streaming_component = Some(Rc::downgrade(&comp));
2411                        app.chat_container
2412                            .borrow_mut()
2413                            .add_child(std::boxed::Box::new(RcRefCellComponent(comp)));
2414                    }
2415                }
2416                StreamDelta::ToolCallDelta { .. } => {}
2417            }
2418        }
2419        E::ToolExecutionStart {
2420            tool_call_id,
2421            tool_name,
2422            args,
2423        } => {
2424            app.pending_tool_executions += 1;
2425            app.streaming_component = None;
2426            let name = tool_name;
2427            let renderer = find_tool_renderer(&app.extensions, &name);
2428            let started_at = std::time::Instant::now();
2429            let (invalidate_tx, invalidate_rx) =
2430                crate::agent::ui::components::ToolExecComponent::make_invalidation_channel();
2431            app.invalidate_rxs.push(invalidate_rx);
2432            let comp: Rc<RefCell<_>> = {
2433                let mut tool = crate::agent::ui::components::ToolExecComponent::new(
2434                    &name,
2435                    renderer,
2436                    args.clone(),
2437                    app.cwd.to_string_lossy().to_string(),
2438                    tool_call_id.clone(),
2439                );
2440                tool.set_started_at(std::time::Instant::now());
2441                tool.set_invalidate_tx(invalidate_tx);
2442                Rc::new(RefCell::new(tool))
2443            };
2444            comp.borrow_mut().set_expanded(app.tools_expanded);
2445            app.pending_tools
2446                .insert(tool_call_id.clone(), Rc::downgrade(&comp));
2447            app.tool_call_start_times
2448                .insert(tool_call_id.clone(), started_at);
2449            chat_add(
2450                app,
2451                std::boxed::Box::new(crate::agent::ui::components::RcToolExec(comp)),
2452            );
2453        }
2454        E::ToolExecutionUpdate {
2455            tool_call_id,
2456            partial_result,
2457            ..
2458        } => {
2459            // Forward partial results to the pending tool component (live streaming).
2460            let partial_text: String = partial_result
2461                .content
2462                .iter()
2463                .filter_map(|c| {
2464                    if let yoagent::types::Content::Text { text } = c {
2465                        Some(text.clone())
2466                    } else {
2467                        None
2468                    }
2469                })
2470                .collect::<Vec<_>>()
2471                .join("");
2472            if !partial_text.is_empty()
2473                && let Some(weak) = app.pending_tools.get(&tool_call_id)
2474                && let Some(comp) = weak.upgrade()
2475            {
2476                comp.borrow_mut().append_output(&partial_text);
2477            }
2478        }
2479        E::ToolExecutionEnd {
2480            tool_call_id,
2481            tool_name: _,
2482            result,
2483            is_error,
2484        } => {
2485            app.pending_tool_executions = app.pending_tool_executions.saturating_sub(1);
2486            let content: String = result
2487                .content
2488                .iter()
2489                .filter_map(|c| {
2490                    if let yoagent::types::Content::Text { text } = c {
2491                        Some(text.clone())
2492                    } else {
2493                        None
2494                    }
2495                })
2496                .collect::<Vec<_>>()
2497                .join("");
2498            if let Some(weak) = app.pending_tools.get(&tool_call_id)
2499                && let Some(comp) = weak.upgrade()
2500            {
2501                comp.borrow_mut()
2502                    .set_result_with_details(&content, is_error, Some(result.details));
2503                app.tool_call_start_times.remove(&tool_call_id);
2504            }
2505        }
2506        E::ProgressMessage {
2507            text, tool_name, ..
2508        } => {
2509            // Bang (") command progress feeds into pending_tools["__bang__"]
2510            if let Some(weak) = app.pending_tools.get("__bang__")
2511                && let Some(comp) = weak.upgrade()
2512            {
2513                comp.borrow_mut().append_output(&text);
2514            } else if tool_name.is_empty() {
2515                // General progress message (not tool-specific) — show as status
2516                app.status_text = Some(text.trim().to_string());
2517            }
2518        }
2519        E::TurnEnd { message, .. } => {
2520            app.streaming_component = None;
2521            // Surface provider errors carried by the turn's final message.
2522            if let Some(err) = crate::agent::types::message_error(&message) {
2523                chat_add(
2524                    app,
2525                    std::boxed::Box::new(InfoMessageComponent::new(format!(
2526                        "Provider error: {}",
2527                        err
2528                    ))),
2529                );
2530            }
2531        }
2532        E::AgentEnd { messages } => {
2533            app.streaming_component = None;
2534            app.is_streaming = false;
2535            app.working.stop();
2536            app.footer.borrow_mut().set_streaming(false);
2537            // Refresh footer cached stats from session at turn end (pull-based)
2538            if let Some(ref s) = app.session {
2539                app.footer.borrow_mut().refresh_from_session(s.session());
2540            }
2541            // Pi-compatible: schedule auto-compaction check after agent ends.
2542            // check_auto_compact() is called asynchronously in the main loop.
2543            app.pending_auto_compact = app.auto_compact;
2544            // Detect silent stops / provider errors: surface any assistant message
2545            // that ended without visible output (empty content or provider error).
2546            // Provider errors with error_message set were never forwarded as
2547            // MessageEnd events (the provider returned Err() without streaming),
2548            // so they must be surfaced here.
2549            for msg in messages.iter().rev() {
2550                if let Some(yoagent::types::Message::Assistant {
2551                    content,
2552                    stop_reason,
2553                    error_message,
2554                    ..
2555                }) = msg.as_llm()
2556                    && stop_reason != &yoagent::types::StopReason::ToolUse
2557                {
2558                    if let Some(err) = error_message {
2559                        chat_add(
2560                            app,
2561                            std::boxed::Box::new(InfoMessageComponent::new(format!(
2562                                "Provider error: {}",
2563                                err
2564                            ))),
2565                        );
2566                        break;
2567                    }
2568                    // Check for any visible content: non-empty text or tool calls.
2569                    // Thinking blocks alone don't count as visible output
2570                    // (they may be hidden or just cut-off thoughts).
2571                    let has_visible = content.iter().any(|c| match c {
2572                        yoagent::types::Content::Text { text } => !text.trim().is_empty(),
2573                        yoagent::types::Content::ToolCall { .. } => true,
2574                        _ => false,
2575                    });
2576                    if !has_visible {
2577                        chat_add(
2578                            app,
2579                            std::boxed::Box::new(InfoMessageComponent::new(
2580                                "The agent returned an empty response. \
2581                                 This can happen when the provider's context \
2582                                 limit is exceeded or the model declined to \
2583                                 respond. Try sending a new message."
2584                                    .to_string(),
2585                            )),
2586                        );
2587                        break;
2588                    }
2589                }
2590            }
2591        }
2592        E::MessageEnd { message } => {
2593            // Special cases: persist as extension (excluded from LLM context).
2594            // Persistence already handled above in the &event match.
2595            if let Some(err) = crate::agent::types::message_error(&message) {
2596                chat_add(
2597                    app,
2598                    std::boxed::Box::new(InfoMessageComponent::new(err.to_string())),
2599                );
2600                let ext = crate::agent::types::extension_message("error", err, true);
2601                if let Some(ref mut s) = app.session {
2602                    s.persist_extension_message(&ext);
2603                }
2604            } else if crate::agent::types::message_is_system_stop(&message) {
2605                let text = crate::agent::types::message_text(&message);
2606                chat_add(
2607                    app,
2608                    std::boxed::Box::new(InfoMessageComponent::new(text.clone())),
2609                );
2610                if let Some(ref mut s) = app.session {
2611                    let ext = crate::agent::types::extension_message("system_stop", text, true);
2612                    s.persist_extension_message(&ext);
2613                }
2614            } else if crate::agent::types::message_is_extension(&message) {
2615                // Extension messages: display in chat (persisted by on_agent_event).
2616                if let Some(text) = crate::agent::types::message_extension_text(&message) {
2617                    chat_add(app, std::boxed::Box::new(InfoMessageComponent::new(text)));
2618                }
2619            }
2620        }
2621        E::InputRejected { reason } => {
2622            let msg = format!("Input rejected: {}", reason);
2623            chat_add(
2624                app,
2625                std::boxed::Box::new(InfoMessageComponent::new(msg.clone())),
2626            );
2627        }
2628    }
2629}
2630
2631/// Parse a ! or !! bang command from input.
2632fn parse_bang_command(input: &str) -> Option<(String, bool)> {
2633    if let Some(rest) = input.strip_prefix("!!") {
2634        let cmd = rest.trim();
2635        if cmd.is_empty() {
2636            None
2637        } else {
2638            Some((cmd.to_string(), true))
2639        }
2640    } else if let Some(rest) = input.strip_prefix('!') {
2641        let cmd = rest.trim();
2642        if cmd.is_empty() {
2643            None
2644        } else {
2645            Some((cmd.to_string(), false))
2646        }
2647    } else {
2648        None
2649    }
2650}
2651
2652/// Format a number with locale-style thousands separators (e.g. 1234 -> "1,234").
2653fn format_number(n: u64) -> String {
2654    let s = n.to_string();
2655    let mut result = String::new();
2656    for (i, c) in s.chars().rev().enumerate() {
2657        if i > 0 && i % 3 == 0 {
2658            result.push(',');
2659        }
2660        result.push(c);
2661    }
2662    result.chars().rev().collect()
2663}
2664
2665/// Format a DateTime for short display (YYYY-MM-DD HH:MM).
2666fn fmt_time_short(dt: &chrono::DateTime<chrono::Utc>) -> String {
2667    dt.format("%Y-%m-%d %H:%M").to_string()
2668}
2669
2670// ── Skills utilities (moved inline from skills.rs) ─────────────────
2671
2672fn xml_escape(s: &str) -> String {
2673    s.replace('&', "&amp;")
2674        .replace('<', "&lt;")
2675        .replace('>', "&gt;")
2676        .replace('"', "&quot;")
2677        .replace('\'', "&apos;")
2678}
2679
2680fn strip_frontmatter(content: &str) -> String {
2681    let content = content.trim_start();
2682    if !content.starts_with("---") {
2683        return content.to_string();
2684    }
2685    let remaining = &content[3..];
2686    let end = match remaining.find("---") {
2687        Some(pos) => pos,
2688        None => return content.to_string(),
2689    };
2690    let body_start = 3 + end + 3;
2691    content[body_start..].trim().to_string()
2692}
2693
2694fn read_skill_body(file_path: &std::path::Path) -> Option<String> {
2695    let content = std::fs::read_to_string(file_path).ok()?;
2696    Some(strip_frontmatter(&content))
2697}
2698
2699fn format_skill_invocation(skill: &yoagent::skills::Skill, extra: Option<&str>) -> String {
2700    let body = read_skill_body(&skill.file_path).unwrap_or_default();
2701    let base = skill.base_dir.to_string_lossy();
2702    let block = format!(
2703        r#"<skill name="{}" location="{}">
2704References are relative to {}.
2705
2706{}
2707</skill>"#,
2708        xml_escape(&skill.name),
2709        xml_escape(&skill.file_path.to_string_lossy()),
2710        base,
2711        body
2712    );
2713    match extra {
2714        Some(instr) if !instr.is_empty() => format!("{}\n\n{}", block, instr),
2715        _ => block,
2716    }
2717}
2718
2719fn expand_skill_command(text: &str, skills: &[yoagent::skills::Skill]) -> String {
2720    if !text.starts_with("/skill:") {
2721        return text.to_string();
2722    }
2723    let rest = &text[7..];
2724    let (skill_name, args) = match rest.find(' ') {
2725        Some(pos) => (&rest[..pos], rest[pos + 1..].trim()),
2726        None => (rest, ""),
2727    };
2728    match skills.iter().find(|s| s.name == skill_name) {
2729        Some(s) => format_skill_invocation(s, if args.is_empty() { None } else { Some(args) }),
2730        None => text.to_string(),
2731    }
2732}