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