Skip to main content

agent_core_tui/
app.rs

1// The main TUI App for LLM agents
2//
3// This App provides a complete terminal UI with:
4// - Chat view with message history
5// - Text input with multi-line support
6// - Slash command popup
7// - Theme picker
8// - Session picker
9// - Question and permission panels
10
11use std::collections::{HashMap, HashSet};
12use std::io;
13use std::sync::Arc;
14use std::time::Instant;
15
16use crossterm::{
17    event::{
18        self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind,
19        KeyModifiers, MouseEventKind,
20    },
21    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
22    ExecutableCommand,
23};
24use ratatui::{
25    layout::Rect,
26    prelude::CrosstermBackend,
27    widgets::{Block, Borders, Paragraph, Wrap},
28    Terminal,
29};
30use throbber_widgets_tui::{Throbber, ThrobberState, BRAILLE_EIGHT_DOUBLE};
31use tokio::runtime::Handle;
32use tokio::sync::mpsc;
33
34use crate::agent::{FromControllerRx, LLMRegistry, ToControllerTx, UiMessage};
35use crate::controller::{
36    ControlCmd, ControllerInputPayload, LLMController, PermissionPanelResponse, PermissionRegistry,
37    ToolResultStatus, TurnId, UserInteractionRegistry,
38};
39
40use super::layout::{LayoutContext, LayoutTemplate, WidgetSizes};
41use super::themes::{render_theme_picker, ThemePickerState};
42use super::commands::{
43    is_slash_command, parse_command,
44    CommandContext, CommandResult, PendingAction, SlashCommand,
45};
46use super::keys::{AppKeyAction, AppKeyResult, DefaultKeyHandler, ExitHandler, KeyBindings, KeyContext, KeyHandler, NavigationHelper};
47use super::widgets::{
48    widget_ids, ChatView, TextInput, ToolStatus, SessionInfo, SessionPickerState,
49    SlashPopupState, Widget, WidgetAction, WidgetKeyContext, WidgetKeyResult, render_session_picker, render_slash_popup,
50    PermissionPanel, QuestionPanel, ConversationView, ConversationViewFactory,
51    StatusBar, StatusBarData, BatchPermissionPanel,
52};
53use super::{app_theme, current_theme_name, default_theme_name, get_theme, init_theme};
54
55const PROMPT: &str = " \u{203A} ";
56const CONTINUATION_INDENT: &str = "   ";
57
58// Pending status messages for chat view spinner
59const PENDING_STATUS_TOOLS: &str = "running tools...";
60const PENDING_STATUS_LLM: &str = "Processing response from LLM...";
61
62/// Callback for dynamic processing messages (e.g., rotating messages).
63/// Called on each render frame when waiting for a response.
64pub type ProcessingMessageFn = Arc<dyn Fn() -> String + Send + Sync>;
65
66/// Configuration for the App
67pub struct AppConfig {
68    /// Agent name (displayed in title bar)
69    pub agent_name: String,
70    /// Agent version
71    pub version: String,
72    /// Slash commands (if None, uses default commands)
73    pub commands: Option<Vec<Box<dyn SlashCommand>>>,
74    /// Extension data available to commands via `ctx.extension::<T>()`
75    pub command_extension: Option<Box<dyn std::any::Any + Send>>,
76    /// Static message shown while processing (default: "Processing request...")
77    pub processing_message: String,
78    /// Optional callback for dynamic messages. When set, overrides processing_message.
79    /// Use this for rotating messages or context-aware status.
80    pub processing_message_fn: Option<ProcessingMessageFn>,
81    /// Error message shown when user submits but no session exists
82    pub error_no_session: Option<String>,
83}
84
85impl std::fmt::Debug for AppConfig {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        f.debug_struct("AppConfig")
88            .field("agent_name", &self.agent_name)
89            .field("version", &self.version)
90            .field("commands", &self.commands.as_ref().map(|c| format!("<{} commands>", c.len())))
91            .field("command_extension", &self.command_extension.as_ref().map(|_| "<extension>"))
92            .field("processing_message", &self.processing_message)
93            .field("processing_message_fn", &self.processing_message_fn.as_ref().map(|_| "<fn>"))
94            .field("error_no_session", &self.error_no_session)
95            .finish()
96    }
97}
98
99impl Default for AppConfig {
100    fn default() -> Self {
101        Self {
102            agent_name: "Agent".to_string(),
103            version: "0.1.0".to_string(),
104            commands: None, // Use default commands
105            command_extension: None,
106            processing_message: "Processing request...".to_string(),
107            processing_message_fn: None,
108            error_no_session: None,
109        }
110    }
111}
112
113/// Terminal UI application with chat, input, and command handling.
114///
115/// Manages the main event loop, widget rendering, and communication with the controller.
116pub struct App {
117    /// Agent name
118    agent_name: String,
119
120    /// Agent version
121    version: String,
122
123    /// All available slash commands
124    commands: Vec<Box<dyn SlashCommand>>,
125
126    /// Extension data available to commands
127    command_extension: Option<Box<dyn std::any::Any + Send>>,
128
129    /// Processing message
130    processing_message: String,
131
132    /// Optional callback for dynamic processing messages
133    processing_message_fn: Option<ProcessingMessageFn>,
134
135    /// Error message shown when user submits but no session exists
136    error_no_session: Option<String>,
137
138    /// Whether the application should quit.
139    pub should_quit: bool,
140
141    /// Sender for messages to the controller
142    to_controller: Option<ToControllerTx>,
143
144    /// Receiver for messages from the controller
145    from_controller: Option<FromControllerRx>,
146
147    /// Reference to the controller for session management
148    controller: Option<Arc<LLMController>>,
149
150    /// LLM provider registry
151    llm_registry: Option<LLMRegistry>,
152
153    /// Tokio runtime handle for async operations
154    runtime_handle: Option<Handle>,
155
156    /// Current active session ID
157    session_id: i64,
158
159    /// Turn counter for user turns
160    user_turn_counter: i64,
161
162    /// Model name for display
163    model_name: String,
164
165    /// Current context usage (input tokens)
166    context_used: i64,
167
168    /// Context limit for the model
169    context_limit: i32,
170
171    /// Throbber animation state for progress indicator
172    throbber_state: ThrobberState,
173
174    /// Whether we're waiting for a response (set immediately on submit, before streaming starts)
175    pub waiting_for_response: bool,
176
177    /// When we started waiting for a response (for elapsed time display)
178    waiting_started: Option<Instant>,
179
180    /// Frame counter for throttling animation speed
181    animation_frame_counter: u8,
182
183    /// Current turn ID we're expecting responses for (to filter stale messages)
184    current_turn_id: Option<TurnId>,
185
186    /// Set of currently executing tool IDs (for spinner display)
187    executing_tools: HashSet<String>,
188
189    /// Registered widgets (keyed by ID)
190    pub widgets: HashMap<&'static str, Box<dyn Widget>>,
191
192    /// Cached sorted priority order for key event handling
193    pub widget_priority_order: Vec<&'static str>,
194
195    /// Filtered slash commands for the popup (indices into self.commands)
196    filtered_command_indices: Vec<usize>,
197
198    /// List of all sessions created in this instance
199    sessions: Vec<SessionInfo>,
200
201    /// Session state storage for conversation views (used for session switching)
202    session_states: HashMap<i64, Box<dyn std::any::Any + Send>>,
203
204    /// The conversation view (decoupled from ChatView)
205    conversation_view: Box<dyn ConversationView>,
206
207    /// Factory for creating new conversation views
208    conversation_factory: ConversationViewFactory,
209
210    /// Custom throbber message (overrides processing_message when set)
211    custom_throbber_message: Option<String>,
212
213    /// User interaction registry for responding to AskUserQuestions
214    user_interaction_registry: Option<Arc<UserInteractionRegistry>>,
215
216    /// Permission registry for responding to AskForPermissions
217    permission_registry: Option<Arc<PermissionRegistry>>,
218
219    /// Layout template for widget arrangement
220    layout_template: LayoutTemplate,
221
222    /// Key handler for customizable key bindings
223    key_handler: Box<dyn KeyHandler>,
224
225    /// Optional exit handler for cleanup before quitting
226    exit_handler: Option<Box<dyn ExitHandler>>,
227}
228
229impl App {
230    /// Create a new App with default configuration.
231    pub fn new() -> Self {
232        Self::with_config(AppConfig::default())
233    }
234
235    /// Create a new App with custom configuration.
236    pub fn with_config(config: AppConfig) -> Self {
237        use super::commands::default_commands;
238
239        // Initialize default theme
240        let theme_name = default_theme_name();
241        if let Some(theme) = get_theme(theme_name) {
242            init_theme(theme_name, theme);
243        }
244
245        // Use provided commands or default commands
246        let commands = config.commands.unwrap_or_else(default_commands);
247
248        // Create a default conversation factory (creates basic ChatView)
249        let default_factory: ConversationViewFactory = Box::new(|| {
250            Box::new(ChatView::new())
251        });
252
253        let mut app = Self {
254            agent_name: config.agent_name,
255            version: config.version,
256            commands,
257            command_extension: config.command_extension,
258            processing_message: config.processing_message,
259            processing_message_fn: config.processing_message_fn,
260            error_no_session: config.error_no_session,
261            should_quit: false,
262            to_controller: None,
263            from_controller: None,
264            controller: None,
265            llm_registry: None,
266            runtime_handle: None,
267            session_id: 0,
268            user_turn_counter: 0,
269            model_name: "Not connected".to_string(),
270            context_used: 0,
271            context_limit: 0,
272            throbber_state: ThrobberState::default(),
273            waiting_for_response: false,
274            waiting_started: None,
275            animation_frame_counter: 0,
276            current_turn_id: None,
277            executing_tools: HashSet::new(),
278            widgets: HashMap::new(),
279            widget_priority_order: Vec::new(),
280            filtered_command_indices: Vec::new(),
281            sessions: Vec::new(),
282            session_states: HashMap::new(),
283            conversation_view: (default_factory)(),
284            conversation_factory: default_factory,
285            custom_throbber_message: None,
286            user_interaction_registry: None,
287            permission_registry: None,
288            layout_template: LayoutTemplate::default(),
289            key_handler: Box::new(DefaultKeyHandler::default()),
290            exit_handler: None,
291        };
292
293        // Register default widgets
294        app.register_widget(StatusBar::new());
295        app.register_widget(TextInput::new());
296
297        app
298    }
299
300    /// Register a widget with the app
301    ///
302    /// The widget will be stored and used for key handling and rendering.
303    /// Widgets are identified by their ID and stored in a priority order.
304    pub fn register_widget<W: Widget>(&mut self, widget: W) {
305        let id = widget.id();
306        self.widgets.insert(id, Box::new(widget));
307        self.rebuild_priority_order();
308    }
309
310    /// Rebuild the priority order cache after widget registration
311    pub fn rebuild_priority_order(&mut self) {
312        let mut order: Vec<_> = self.widgets.keys().copied().collect();
313        order.sort_by(|a, b| {
314            let priority_a = self.widgets.get(a).map(|w| w.priority()).unwrap_or(0);
315            let priority_b = self.widgets.get(b).map(|w| w.priority()).unwrap_or(0);
316            priority_b.cmp(&priority_a) // Descending order (higher priority first)
317        });
318        self.widget_priority_order = order;
319    }
320
321    /// Get a widget by ID
322    pub fn widget<W: Widget + 'static>(&self, id: &str) -> Option<&W> {
323        self.widgets.get(id).and_then(|w| w.as_any().downcast_ref::<W>())
324    }
325
326    /// Get a widget by ID (mutable)
327    pub fn widget_mut<W: Widget + 'static>(&mut self, id: &str) -> Option<&mut W> {
328        self.widgets.get_mut(id).and_then(|w| w.as_any_mut().downcast_mut::<W>())
329    }
330
331    /// Check if a widget is registered
332    pub fn has_widget(&self, id: &str) -> bool {
333        self.widgets.contains_key(id)
334    }
335
336    /// Check if any registered widget blocks input
337    fn any_widget_blocks_input(&self) -> bool {
338        self.widgets.values().any(|w| w.is_active() && w.blocks_input())
339    }
340
341    /// Set the conversation view factory
342    ///
343    /// The factory is called to create new conversation views when:
344    /// - Creating a new session
345    /// - Clearing the current conversation (/clear command)
346    ///
347    /// # Example
348    ///
349    /// ```ignore
350    /// app.set_conversation_factory(|| {
351    ///     Box::new(ChatView::new()
352    ///         .with_title("My Agent")
353    ///         .with_initial_content(welcome_renderer))
354    /// });
355    /// ```
356    pub fn set_conversation_factory<F>(&mut self, factory: F)
357    where
358        F: Fn() -> Box<dyn ConversationView> + Send + Sync + 'static,
359    {
360        self.conversation_factory = Box::new(factory);
361        // Replace current conversation view with one from the new factory
362        self.conversation_view = (self.conversation_factory)();
363    }
364
365    /// Get a reference to the TextInput widget if registered
366    fn input(&self) -> Option<&TextInput> {
367        self.widget::<TextInput>(widget_ids::TEXT_INPUT)
368    }
369
370    /// Get a mutable reference to the TextInput widget if registered
371    fn input_mut(&mut self) -> Option<&mut TextInput> {
372        self.widget_mut::<TextInput>(widget_ids::TEXT_INPUT)
373    }
374
375    /// Check if the conversation view is currently streaming
376    fn is_chat_streaming(&self) -> bool {
377        self.conversation_view.is_streaming()
378    }
379
380    /// Get the agent name
381    pub fn agent_name(&self) -> &str {
382        &self.agent_name
383    }
384
385    /// Get the agent version
386    pub fn version(&self) -> &str {
387        &self.version
388    }
389
390    /// Set the channel for sending messages to the controller
391    pub fn set_to_controller(&mut self, tx: ToControllerTx) {
392        self.to_controller = Some(tx);
393    }
394
395    /// Set the channel for receiving messages from the controller
396    pub fn set_from_controller(&mut self, rx: FromControllerRx) {
397        self.from_controller = Some(rx);
398    }
399
400    /// Set the controller reference for session management
401    pub fn set_controller(&mut self, controller: Arc<LLMController>) {
402        self.controller = Some(controller);
403    }
404
405    /// Set the LLM provider registry
406    pub fn set_llm_registry(&mut self, registry: LLMRegistry) {
407        self.llm_registry = Some(registry);
408    }
409
410    /// Set the tokio runtime handle
411    pub fn set_runtime_handle(&mut self, handle: Handle) {
412        self.runtime_handle = Some(handle);
413    }
414
415    /// Set the user interaction registry
416    pub fn set_user_interaction_registry(&mut self, registry: Arc<UserInteractionRegistry>) {
417        self.user_interaction_registry = Some(registry);
418    }
419
420    /// Set the permission registry
421    pub fn set_permission_registry(&mut self, registry: Arc<PermissionRegistry>) {
422        self.permission_registry = Some(registry);
423    }
424
425    /// Set the session ID
426    pub fn set_session_id(&mut self, id: i64) {
427        self.session_id = id;
428    }
429
430    /// Set the model name
431    pub fn set_model_name(&mut self, name: impl Into<String>) {
432        self.model_name = name.into();
433    }
434
435    /// Set the context limit
436    pub fn set_context_limit(&mut self, limit: i32) {
437        self.context_limit = limit;
438    }
439
440    /// Set the layout template
441    pub fn set_layout(&mut self, template: LayoutTemplate) {
442        self.layout_template = template;
443    }
444
445    /// Set a custom key handler.
446    ///
447    /// This allows full control over key handling behavior.
448    /// For simpler customization, use [`Self::set_key_bindings`] instead.
449    pub fn set_key_handler<H: KeyHandler>(&mut self, handler: H) {
450        self.key_handler = Box::new(handler);
451    }
452
453    /// Set a boxed key handler directly.
454    ///
455    /// This is useful when you have a `Box<dyn KeyHandler>` already.
456    pub fn set_key_handler_boxed(&mut self, handler: Box<dyn KeyHandler>) {
457        self.key_handler = handler;
458    }
459
460    /// Set custom key bindings using the default handler.
461    ///
462    /// This is a simpler alternative to [`Self::set_key_handler`] when you
463    /// only need to change which keys trigger which actions.
464    pub fn set_key_bindings(&mut self, bindings: KeyBindings) {
465        self.key_handler = Box::new(DefaultKeyHandler::new(bindings));
466    }
467
468    /// Set an exit handler for cleanup before quitting.
469    ///
470    /// The exit handler's `on_exit()` method is called when the user
471    /// confirms exit. If it returns `false`, the exit is cancelled.
472    pub fn set_exit_handler<H: ExitHandler>(&mut self, handler: H) {
473        self.exit_handler = Some(Box::new(handler));
474    }
475
476    /// Set a boxed exit handler directly.
477    pub fn set_exit_handler_boxed(&mut self, handler: Box<dyn ExitHandler>) {
478        self.exit_handler = Some(handler);
479    }
480
481    /// Compute widget sizes for layout computation
482    fn compute_widget_sizes(&self, frame_height: u16) -> WidgetSizes {
483        let mut heights = HashMap::new();
484        let mut is_active = HashMap::new();
485
486        for (id, widget) in &self.widgets {
487            heights.insert(*id, widget.required_height(frame_height));
488            is_active.insert(*id, widget.is_active());
489        }
490
491        WidgetSizes { heights, is_active }
492    }
493
494    /// Build the layout context for rendering
495    fn build_layout_context<'a>(
496        &self,
497        frame_area: Rect,
498        show_throbber: bool,
499        prompt_len: usize,
500        indent_len: usize,
501        theme: &'a super::themes::Theme,
502    ) -> LayoutContext<'a> {
503        let frame_width = frame_area.width as usize;
504        let input_visual_lines = self
505            .input()
506            .map(|i| i.visual_line_count(frame_width, prompt_len, indent_len))
507            .unwrap_or(1);
508
509        let mut active_widgets = HashSet::new();
510        for (id, widget) in &self.widgets {
511            if widget.is_active() {
512                active_widgets.insert(*id);
513            }
514        }
515
516        LayoutContext {
517            frame_area,
518            show_throbber,
519            input_visual_lines,
520            theme,
521            active_widgets,
522        }
523    }
524
525    pub fn submit_message(&mut self) {
526        // Get content from input widget (if registered)
527        let content = match self.input_mut() {
528            Some(input) => input.take(),
529            None => return, // No input widget registered
530        };
531        if content.trim().is_empty() {
532            return;
533        }
534
535        // Check if this is a slash command
536        if is_slash_command(&content) {
537            self.execute_command(&content);
538            return;
539        }
540
541        // Add user message to chat and re-enable auto-scroll (user wants to see response)
542        self.conversation_view.enable_auto_scroll();
543        self.conversation_view.add_user_message(content.clone());
544
545        // Check if we have an active session
546        if self.session_id == 0 {
547            let msg = self.error_no_session.clone()
548                .unwrap_or_else(|| "No active session. Use /new-session to create one.".to_string());
549            self.conversation_view.add_system_message(msg);
550            return;
551        }
552
553        // Send to controller if channel is available
554        if let Some(ref tx) = self.to_controller {
555            self.user_turn_counter += 1;
556            let turn_id = TurnId::new_user_turn(self.user_turn_counter);
557            let payload = ControllerInputPayload::data(self.session_id, content, turn_id);
558
559            // Try to send (non-blocking)
560            if tx.try_send(payload).is_err() {
561                self.conversation_view.add_system_message("Failed to send message to controller".to_string());
562            } else {
563                // Immediately show throbber (before streaming starts)
564                self.waiting_for_response = true;
565                self.waiting_started = Some(Instant::now());
566                // Track the expected turn ID to filter stale messages
567                self.current_turn_id = Some(TurnId::new_user_turn(self.user_turn_counter));
568            }
569        }
570    }
571
572    /// Interrupt the current LLM request
573    pub fn interrupt_request(&mut self) {
574        // Only interrupt if we're actually waiting/streaming/executing tools
575        if !self.waiting_for_response
576            && !self.is_chat_streaming()
577            && self.executing_tools.is_empty()
578        {
579            return;
580        }
581
582        // Send interrupt command to controller
583        if let Some(ref tx) = self.to_controller {
584            let payload = ControllerInputPayload::control(self.session_id, ControlCmd::Interrupt);
585            if tx.try_send(payload).is_ok() {
586                // Reset waiting state immediately for responsive UI
587                self.waiting_for_response = false;
588                self.waiting_started = None;
589                self.executing_tools.clear();
590                // Keep partial streaming content visible (save it as a message)
591                self.conversation_view.complete_streaming();
592                // Clear turn ID so any stale messages from this turn are ignored
593                self.current_turn_id = None;
594                self.conversation_view.add_system_message("Request cancelled".to_string());
595            }
596        }
597    }
598
599    /// Execute a slash command using the trait-based system
600    fn execute_command(&mut self, input: &str) {
601        let Some((cmd_name, args)) = parse_command(input) else {
602            self.conversation_view.add_system_message("Invalid command format".to_string());
603            return;
604        };
605
606        // Find the command by name
607        let cmd_idx = self.commands.iter().position(|c| c.name() == cmd_name);
608        let Some(cmd_idx) = cmd_idx else {
609            self.conversation_view.add_system_message(format!("Unknown command: /{}", cmd_name));
610            return;
611        };
612
613        // Execute command and collect results (scoped to drop borrows)
614        let (result, pending_actions) = {
615            // Temporarily take command_extension to avoid borrow issues
616            let extension = self.command_extension.take();
617            let extension_ref = extension.as_ref().map(|e| e.as_ref() as &dyn std::any::Any);
618
619            let mut ctx = CommandContext::new(
620                self.session_id,
621                &self.agent_name,
622                &self.version,
623                &self.commands,
624                &mut *self.conversation_view,
625                self.to_controller.as_ref(),
626                extension_ref,
627            );
628
629            // Execute the command
630            let result = self.commands[cmd_idx].execute(args, &mut ctx);
631            let pending_actions = ctx.take_pending_actions();
632
633            // Restore extension before leaving scope
634            self.command_extension = extension;
635
636            (result, pending_actions)
637        };
638
639        // Handle pending actions (now that ctx is dropped)
640        for action in pending_actions {
641            match action {
642                PendingAction::OpenThemePicker => self.cmd_themes(),
643                PendingAction::OpenSessionPicker => self.cmd_sessions(),
644                PendingAction::ClearConversation => self.cmd_clear(),
645                PendingAction::CompactConversation => self.cmd_compact(),
646                PendingAction::CreateNewSession => { self.cmd_new_session(); }
647                PendingAction::Quit => { self.should_quit = true; }
648            }
649        }
650
651        // Handle result
652        match result {
653            CommandResult::Ok | CommandResult::Handled => {}
654            CommandResult::Message(msg) => {
655                self.conversation_view.add_system_message(msg);
656            }
657            CommandResult::Error(err) => {
658                self.conversation_view.add_system_message(format!("Error: {}", err));
659            }
660            CommandResult::Quit => {
661                self.should_quit = true;
662            }
663        }
664    }
665
666    fn cmd_clear(&mut self) {
667        // Create a fresh conversation view using the factory
668        self.conversation_view = (self.conversation_factory)();
669        self.user_turn_counter = 0;
670
671        // Send Clear command to controller to clear session conversation
672        if self.session_id != 0 {
673            if let Some(ref tx) = self.to_controller {
674                let payload =
675                    ControllerInputPayload::control(self.session_id, ControlCmd::Clear);
676                if let Err(e) = tx.try_send(payload) {
677                    tracing::warn!("Failed to send clear command to controller: {}", e);
678                }
679            }
680        }
681    }
682
683    fn cmd_compact(&mut self) {
684        // Check if we have an active session
685        if self.session_id == 0 {
686            self.conversation_view.add_system_message("No active session to compact".to_string());
687            return;
688        }
689
690        // Send Compact command to controller
691        if let Some(ref tx) = self.to_controller {
692            let payload = ControllerInputPayload::control(self.session_id, ControlCmd::Compact);
693            if tx.try_send(payload).is_ok() {
694                // Show spinner with "compacting..." message
695                self.waiting_for_response = true;
696                self.waiting_started = Some(Instant::now());
697                self.custom_throbber_message = Some("compacting...".to_string());
698            } else {
699                self.conversation_view.add_system_message("Failed to send compact command".to_string());
700            }
701        }
702    }
703
704    fn cmd_new_session(&mut self) -> String {
705        let Some(ref controller) = self.controller else {
706            return "Error: Controller not available".to_string();
707        };
708
709        let Some(ref handle) = self.runtime_handle else {
710            return "Error: Runtime not available".to_string();
711        };
712
713        let Some(ref registry) = self.llm_registry else {
714            return "Error: No LLM providers configured.\nSet ANTHROPIC_API_KEY or create config file".to_string();
715        };
716
717        // Get the default config from registry
718        let Some(config) = registry.get_default() else {
719            return "Error: No LLM providers configured.\nSet ANTHROPIC_API_KEY or create config file".to_string();
720        };
721
722        let model = config.model.clone();
723        let context_limit = config.context_limit;
724        let config = config.clone();
725
726        // Create session using the runtime handle
727        let controller = controller.clone();
728        let session_id = match handle.block_on(async { controller.create_session(config).await }) {
729            Ok(id) => id,
730            Err(e) => {
731                return format!("Error: Failed to create session: {}", e);
732            }
733        };
734
735        // Add session to the sessions list
736        let session_info = SessionInfo::new(session_id, model.clone(), context_limit);
737        self.sessions.push(session_info);
738
739        // Save current conversation state before switching to new session
740        if self.session_id != 0 {
741            let state = self.conversation_view.save_state();
742            self.session_states.insert(self.session_id, state);
743        }
744
745        // Create new conversation view for the new session
746        self.conversation_view = (self.conversation_factory)();
747
748        self.session_id = session_id;
749        self.model_name = model.clone();
750        self.context_limit = context_limit;
751        self.context_used = 0;
752        self.user_turn_counter = 0;
753
754        // Return empty string - no system message needed
755        String::new()
756    }
757
758    fn cmd_themes(&mut self) {
759        if let Some(widget) = self.widgets.get_mut(widget_ids::THEME_PICKER) {
760            if let Some(picker) = widget.as_any_mut().downcast_mut::<ThemePickerState>() {
761                let current_name = current_theme_name();
762                let current_theme = app_theme();
763                picker.activate(&current_name, current_theme);
764            }
765        }
766    }
767
768    fn cmd_sessions(&mut self) {
769        // Update context_used for current session before displaying
770        if let Some(session) = self.sessions.iter_mut().find(|s| s.id == self.session_id) {
771            session.context_used = self.context_used;
772        }
773
774        if let Some(widget) = self.widgets.get_mut(widget_ids::SESSION_PICKER) {
775            if let Some(picker) = widget.as_any_mut().downcast_mut::<SessionPickerState>() {
776                picker.activate(self.sessions.clone(), self.session_id);
777            }
778        }
779    }
780
781    /// Switch to a different session by ID
782    pub fn switch_session(&mut self, session_id: i64) {
783        // Don't switch if already on this session
784        if session_id == self.session_id {
785            return;
786        }
787
788        // Update context for current session before switching
789        if let Some(session) = self.sessions.iter_mut().find(|s| s.id == self.session_id) {
790            session.context_used = self.context_used;
791        }
792
793        // Save current conversation state
794        let state = self.conversation_view.save_state();
795        self.session_states.insert(self.session_id, state);
796
797        // Find the target session
798        if let Some(session) = self.sessions.iter().find(|s| s.id == session_id) {
799            self.session_id = session_id;
800            self.model_name = session.model.clone();
801            self.context_used = session.context_used;
802            self.context_limit = session.context_limit;
803            self.user_turn_counter = 0;
804
805            // Restore conversation state for this session, or create new one
806            if let Some(stored_state) = self.session_states.remove(&session_id) {
807                self.conversation_view.restore_state(stored_state);
808            } else {
809                self.conversation_view = (self.conversation_factory)();
810            }
811        }
812    }
813
814    /// Add a session to the sessions list
815    pub fn add_session(&mut self, info: SessionInfo) {
816        self.sessions.push(info);
817    }
818
819    /// Submit the question panel response
820    fn submit_question_panel_response(&mut self, tool_use_id: String, response: crate::controller::AskUserQuestionsResponse) {
821        // Respond to the interaction via the registry
822        if let (Some(registry), Some(handle)) =
823            (&self.user_interaction_registry, &self.runtime_handle)
824        {
825            let registry = registry.clone();
826            handle.spawn(async move {
827                if let Err(e) = registry.respond(&tool_use_id, response).await {
828                    tracing::error!(%tool_use_id, ?e, "Failed to respond to interaction");
829                }
830            });
831        }
832
833        // Deactivate the widget
834        if let Some(widget) = self.widgets.get_mut(widget_ids::QUESTION_PANEL) {
835            if let Some(panel) = widget.as_any_mut().downcast_mut::<QuestionPanel>() {
836                panel.deactivate();
837            }
838        }
839    }
840
841    /// Cancel the question panel (closes without responding, tool will get an error)
842    fn cancel_question_panel_response(&mut self, tool_use_id: String) {
843        // Cancel the pending interaction via the registry
844        if let (Some(registry), Some(handle)) =
845            (&self.user_interaction_registry, &self.runtime_handle)
846        {
847            let registry = registry.clone();
848            handle.spawn(async move {
849                if let Err(e) = registry.cancel(&tool_use_id).await {
850                    tracing::warn!(%tool_use_id, ?e, "Failed to cancel interaction");
851                }
852            });
853        }
854
855        // Deactivate the widget
856        if let Some(widget) = self.widgets.get_mut(widget_ids::QUESTION_PANEL) {
857            if let Some(panel) = widget.as_any_mut().downcast_mut::<QuestionPanel>() {
858                panel.deactivate();
859            }
860        }
861    }
862
863    /// Submit the permission panel response
864    fn submit_permission_panel_response(&mut self, tool_use_id: String, response: PermissionPanelResponse) {
865        // Respond to the permission request via the registry
866        if let (Some(registry), Some(handle)) = (&self.permission_registry, &self.runtime_handle) {
867            let registry = registry.clone();
868            handle.spawn(async move {
869                if let Err(e) = registry.respond_to_request(&tool_use_id, response).await {
870                    tracing::error!(%tool_use_id, ?e, "Failed to respond to permission request");
871                }
872            });
873        }
874
875        // Deactivate the widget
876        if let Some(widget) = self.widgets.get_mut(widget_ids::PERMISSION_PANEL) {
877            if let Some(panel) = widget.as_any_mut().downcast_mut::<PermissionPanel>() {
878                panel.deactivate();
879            }
880        }
881    }
882
883    /// Cancel the permission panel (closes without responding, denies permission)
884    fn cancel_permission_panel_response(&mut self, tool_use_id: String) {
885        // Cancel the pending permission via the registry
886        if let (Some(registry), Some(handle)) = (&self.permission_registry, &self.runtime_handle) {
887            let registry = registry.clone();
888            handle.spawn(async move {
889                if let Err(e) = registry.cancel(&tool_use_id).await {
890                    tracing::warn!(%tool_use_id, ?e, "Failed to cancel permission request");
891                }
892            });
893        }
894
895        // Deactivate the widget
896        if let Some(widget) = self.widgets.get_mut(widget_ids::PERMISSION_PANEL) {
897            if let Some(panel) = widget.as_any_mut().downcast_mut::<PermissionPanel>() {
898                panel.deactivate();
899            }
900        }
901    }
902
903    /// Submit the batch permission panel response
904    fn submit_batch_permission_response(
905        &mut self,
906        batch_id: String,
907        response: crate::permissions::BatchPermissionResponse,
908    ) {
909        // Respond to the batch permission request via the registry
910        if let (Some(registry), Some(handle)) = (&self.permission_registry, &self.runtime_handle) {
911            let registry = registry.clone();
912            handle.spawn(async move {
913                if let Err(e) = registry.respond_to_batch(&batch_id, response).await {
914                    tracing::error!(%batch_id, ?e, "Failed to respond to batch permission request");
915                }
916            });
917        }
918
919        // Deactivate the widget
920        if let Some(widget) = self.widgets.get_mut(widget_ids::BATCH_PERMISSION_PANEL) {
921            if let Some(panel) = widget
922                .as_any_mut()
923                .downcast_mut::<crate::widgets::BatchPermissionPanel>()
924            {
925                panel.deactivate();
926            }
927        }
928    }
929
930    /// Cancel the batch permission panel (closes without responding, denies all)
931    fn cancel_batch_permission_response(&mut self, batch_id: String) {
932        // Cancel the pending batch permission via the registry
933        if let (Some(registry), Some(handle)) = (&self.permission_registry, &self.runtime_handle) {
934            let registry = registry.clone();
935            handle.spawn(async move {
936                if let Err(e) = registry.cancel_batch(&batch_id).await {
937                    tracing::warn!(%batch_id, ?e, "Failed to cancel batch permission request");
938                }
939            });
940        }
941
942        // Deactivate the widget
943        if let Some(widget) = self.widgets.get_mut(widget_ids::BATCH_PERMISSION_PANEL) {
944            if let Some(panel) = widget
945                .as_any_mut()
946                .downcast_mut::<crate::widgets::BatchPermissionPanel>()
947            {
948                panel.deactivate();
949            }
950        }
951    }
952
953    /// Process any pending messages from the controller
954    fn process_controller_messages(&mut self) {
955        // Collect all available messages first to avoid borrow issues
956        let mut messages = Vec::new();
957
958        if let Some(ref mut rx) = self.from_controller {
959            loop {
960                match rx.try_recv() {
961                    Ok(msg) => messages.push(msg),
962                    Err(mpsc::error::TryRecvError::Empty) => break,
963                    Err(mpsc::error::TryRecvError::Disconnected) => {
964                        tracing::warn!("Controller channel disconnected");
965                        break;
966                    }
967                }
968            }
969        }
970
971        // Process collected messages
972        for msg in messages {
973            self.handle_ui_message(msg);
974        }
975    }
976
977    /// Handle a UI message from the controller
978    fn handle_ui_message(&mut self, msg: UiMessage) {
979        match msg {
980            UiMessage::TextChunk { text, turn_id, .. } => {
981                // Filter stale messages from cancelled requests
982                if !self.is_current_turn(&turn_id) {
983                    return;
984                }
985                self.conversation_view.append_streaming(&text);
986            }
987            UiMessage::Display { message, .. } => {
988                self.conversation_view.add_system_message(message);
989            }
990            UiMessage::Complete {
991                turn_id,
992                stop_reason,
993                ..
994            } => {
995                // Filter stale Complete messages from cancelled requests
996                if !self.is_current_turn(&turn_id) {
997                    return;
998                }
999
1000                // Check if this is a tool_use stop - if so, tools will execute
1001                let is_tool_use = stop_reason.as_deref() == Some("tool_use");
1002
1003                self.conversation_view.complete_streaming();
1004
1005                // Only stop waiting if this is NOT a tool_use stop
1006                if !is_tool_use {
1007                    self.waiting_for_response = false;
1008                    self.waiting_started = None;
1009                }
1010            }
1011            UiMessage::TokenUpdate {
1012                input_tokens,
1013                context_limit,
1014                ..
1015            } => {
1016                self.context_used = input_tokens;
1017                self.context_limit = context_limit;
1018            }
1019            UiMessage::Error { error, turn_id, .. } => {
1020                // Clear state if: current turn matches OR we have active turn but error has no turn_id
1021                // The second condition prevents stale state when errors don't include turn_id
1022                let should_process = self.is_current_turn(&turn_id)
1023                    || (self.current_turn_id.is_some() && turn_id.is_none());
1024                if !should_process {
1025                    return;
1026                }
1027                self.conversation_view.complete_streaming();
1028                self.waiting_for_response = false;
1029                self.waiting_started = None;
1030                self.current_turn_id = None;
1031                self.conversation_view.add_system_message(format!("Error: {}", error));
1032            }
1033            UiMessage::System { message, .. } => {
1034                self.conversation_view.add_system_message(message);
1035            }
1036            UiMessage::ToolExecuting {
1037                tool_use_id,
1038                display_name,
1039                display_title,
1040                ..
1041            } => {
1042                self.executing_tools.insert(tool_use_id.clone());
1043                self.conversation_view.add_tool_message(&tool_use_id, &display_name, &display_title);
1044            }
1045            UiMessage::ToolCompleted {
1046                tool_use_id,
1047                status,
1048                error,
1049                ..
1050            } => {
1051                self.executing_tools.remove(&tool_use_id);
1052                let tool_status = if status == ToolResultStatus::Success {
1053                    ToolStatus::Completed
1054                } else {
1055                    ToolStatus::Failed(error.unwrap_or_default())
1056                };
1057                self.conversation_view.update_tool_status(&tool_use_id, tool_status);
1058            }
1059            UiMessage::CommandComplete {
1060                command,
1061                success,
1062                message,
1063                ..
1064            } => {
1065                self.waiting_for_response = false;
1066                self.waiting_started = None;
1067                self.custom_throbber_message = None;
1068
1069                match command {
1070                    ControlCmd::Compact => {
1071                        if let Some(msg) = message {
1072                            self.conversation_view.add_system_message(msg);
1073                        }
1074                    }
1075                    ControlCmd::Clear => {}
1076                    _ => {
1077                        tracing::debug!(?command, ?success, "Command completed");
1078                    }
1079                }
1080            }
1081            UiMessage::UserInteractionRequired {
1082                session_id,
1083                tool_use_id,
1084                request,
1085                turn_id,
1086            } => {
1087                if session_id == self.session_id {
1088                    self.conversation_view.update_tool_status(&tool_use_id, ToolStatus::WaitingForUser);
1089                    // Activate via widget registry if registered
1090                    if let Some(widget) = self.widgets.get_mut(widget_ids::QUESTION_PANEL) {
1091                        if let Some(panel) = widget.as_any_mut().downcast_mut::<QuestionPanel>() {
1092                            panel.activate(tool_use_id, session_id, request, turn_id);
1093                        }
1094                    }
1095                }
1096            }
1097            UiMessage::PermissionRequired {
1098                session_id,
1099                tool_use_id,
1100                request,
1101                turn_id,
1102            } => {
1103                if session_id == self.session_id {
1104                    self.conversation_view.update_tool_status(&tool_use_id, ToolStatus::WaitingForUser);
1105                    // Activate via widget registry if registered
1106                    if let Some(widget) = self.widgets.get_mut(widget_ids::PERMISSION_PANEL) {
1107                        if let Some(panel) = widget.as_any_mut().downcast_mut::<PermissionPanel>() {
1108                            panel.activate(tool_use_id, session_id, request, turn_id);
1109                        }
1110                    }
1111                }
1112            }
1113            UiMessage::BatchPermissionRequired {
1114                session_id,
1115                batch,
1116                turn_id,
1117            } => {
1118                if session_id == self.session_id {
1119                    // Mark all tools in the batch as waiting for user
1120                    for request in &batch.requests {
1121                        self.conversation_view.update_tool_status(&request.id, ToolStatus::WaitingForUser);
1122                    }
1123                    // Activate BatchPermissionPanel widget if registered
1124                    if let Some(widget) = self.widgets.get_mut(widget_ids::BATCH_PERMISSION_PANEL) {
1125                        if let Some(panel) = widget.as_any_mut().downcast_mut::<BatchPermissionPanel>() {
1126                            panel.activate(session_id, batch, turn_id);
1127                        }
1128                    }
1129                }
1130            }
1131        }
1132    }
1133
1134    /// Check if the given turn_id matches the current expected turn
1135    fn is_current_turn(&self, turn_id: &Option<TurnId>) -> bool {
1136        match (&self.current_turn_id, turn_id) {
1137            (Some(current), Some(incoming)) => current == incoming,
1138            (None, _) => false,
1139            (Some(_), None) => false,
1140        }
1141    }
1142
1143    pub fn scroll_up(&mut self) {
1144        self.conversation_view.scroll_up();
1145    }
1146
1147    pub fn scroll_down(&mut self) {
1148        self.conversation_view.scroll_down();
1149    }
1150
1151    /// Get the current working directory with home directory substitution
1152    fn get_cwd(&self) -> String {
1153        std::env::current_dir()
1154            .map(|p| {
1155                let path_str = p.display().to_string();
1156                if let Some(home) = std::env::var_os("HOME") {
1157                    let home_str = home.to_string_lossy();
1158                    if path_str.starts_with(home_str.as_ref()) {
1159                        return format!("~{}", &path_str[home_str.len()..]);
1160                    }
1161                }
1162                path_str
1163            })
1164            .unwrap_or_else(|_| "unknown".to_string())
1165    }
1166
1167    /// Handle key events
1168    fn handle_key(&mut self, key: KeyCode, modifiers: KeyModifiers) {
1169        // Build key context for the handler
1170        let key_event = KeyEvent::new(key, modifiers);
1171        let context = KeyContext {
1172            input_empty: self.input().map(|i| i.is_empty()).unwrap_or(true),
1173            is_processing: self.waiting_for_response || self.is_chat_streaming(),
1174            widget_blocking: self.any_widget_blocks_input(),
1175        };
1176
1177        // Let the key handler process first
1178        // (handler manages exit confirmation state internally)
1179        let result = self.key_handler.handle_key(key_event, &context);
1180
1181        match result {
1182            AppKeyResult::Handled => return,
1183            AppKeyResult::Action(action) => {
1184                self.execute_key_action(action);
1185                return;
1186            }
1187            AppKeyResult::NotHandled => {
1188                // Continue to widget dispatch
1189            }
1190        }
1191
1192        // Try to send key event to registered widgets (by priority order)
1193        let theme = app_theme();
1194        let nav = NavigationHelper::new(self.key_handler.bindings());
1195        let widget_ctx = WidgetKeyContext { theme: &theme, nav };
1196
1197        // Collect widget IDs to check (we need to avoid borrow issues)
1198        let widget_ids_to_check: Vec<&'static str> = self.widget_priority_order.clone();
1199
1200        for widget_id in widget_ids_to_check {
1201            if let Some(widget) = self.widgets.get_mut(widget_id) {
1202                if widget.is_active() {
1203                    match widget.handle_key(key_event, &widget_ctx) {
1204                        WidgetKeyResult::Handled => return,
1205                        WidgetKeyResult::Action(action) => {
1206                            self.process_widget_action(action);
1207                            return;
1208                        }
1209                        WidgetKeyResult::NotHandled => {
1210                            // Continue to next widget or fall through to input handling
1211                        }
1212                    }
1213                }
1214            }
1215        }
1216
1217        // Handle slash popup specially (needs input buffer access)
1218        if self.is_slash_popup_active() {
1219            self.handle_slash_popup_key(key);
1220            return;
1221        }
1222    }
1223
1224    /// Execute an application-level key action.
1225    fn execute_key_action(&mut self, action: AppKeyAction) {
1226        match action {
1227            AppKeyAction::MoveUp => {
1228                if let Some(input) = self.input_mut() {
1229                    input.move_up();
1230                }
1231            }
1232            AppKeyAction::MoveDown => {
1233                if let Some(input) = self.input_mut() {
1234                    input.move_down();
1235                }
1236            }
1237            AppKeyAction::MoveLeft => {
1238                if let Some(input) = self.input_mut() {
1239                    input.move_left();
1240                }
1241            }
1242            AppKeyAction::MoveRight => {
1243                if let Some(input) = self.input_mut() {
1244                    input.move_right();
1245                }
1246            }
1247            AppKeyAction::MoveLineStart => {
1248                if let Some(input) = self.input_mut() {
1249                    input.move_to_line_start();
1250                }
1251            }
1252            AppKeyAction::MoveLineEnd => {
1253                if let Some(input) = self.input_mut() {
1254                    input.move_to_line_end();
1255                }
1256            }
1257            AppKeyAction::DeleteCharBefore => {
1258                if let Some(input) = self.input_mut() {
1259                    input.delete_char_before();
1260                }
1261            }
1262            AppKeyAction::DeleteCharAt => {
1263                if let Some(input) = self.input_mut() {
1264                    input.delete_char_at();
1265                }
1266            }
1267            AppKeyAction::KillLine => {
1268                if let Some(input) = self.input_mut() {
1269                    input.kill_line();
1270                }
1271            }
1272            AppKeyAction::InsertNewline => {
1273                if let Some(input) = self.input_mut() {
1274                    input.insert_char('\n');
1275                }
1276            }
1277            AppKeyAction::InsertChar(c) => {
1278                if let Some(input) = self.input_mut() {
1279                    input.insert_char(c);
1280                }
1281                // Check if we just typed "/" as the first character for slash popup
1282                if c == '/' && self.input().map(|i| i.buffer() == "/").unwrap_or(false) {
1283                    self.activate_slash_popup();
1284                }
1285            }
1286            AppKeyAction::Submit => {
1287                self.submit_message();
1288            }
1289            AppKeyAction::Interrupt => {
1290                self.interrupt_request();
1291            }
1292            AppKeyAction::Quit => {
1293                self.should_quit = true;
1294            }
1295            AppKeyAction::RequestExit => {
1296                // Call exit handler if set, otherwise just quit
1297                let should_quit = self.exit_handler
1298                    .as_mut()
1299                    .map(|h| h.on_exit())
1300                    .unwrap_or(true);
1301                if should_quit {
1302                    self.should_quit = true;
1303                }
1304            }
1305            AppKeyAction::ActivateSlashPopup => {
1306                self.activate_slash_popup();
1307            }
1308            AppKeyAction::Custom(_) => {
1309                // Custom actions are handled by the custom_action_handler if set.
1310                // Users implementing custom key bindings should set a handler
1311                // via with_custom_action_handler() to receive these actions.
1312            }
1313        }
1314    }
1315
1316    /// Process an action returned by a widget
1317    fn process_widget_action(&mut self, action: WidgetAction) {
1318        match action {
1319            WidgetAction::SubmitQuestion { tool_use_id, response } => {
1320                self.submit_question_panel_response(tool_use_id, response);
1321            }
1322            WidgetAction::CancelQuestion { tool_use_id } => {
1323                self.cancel_question_panel_response(tool_use_id);
1324            }
1325            WidgetAction::SubmitPermission { tool_use_id, response } => {
1326                self.submit_permission_panel_response(tool_use_id, response);
1327            }
1328            WidgetAction::CancelPermission { tool_use_id } => {
1329                self.cancel_permission_panel_response(tool_use_id);
1330            }
1331            WidgetAction::SubmitBatchPermission { batch_id, response } => {
1332                self.submit_batch_permission_response(batch_id, response);
1333            }
1334            WidgetAction::CancelBatchPermission { batch_id } => {
1335                self.cancel_batch_permission_response(batch_id);
1336            }
1337            WidgetAction::SwitchSession { session_id } => {
1338                self.switch_session(session_id);
1339            }
1340            WidgetAction::ExecuteCommand { command } => {
1341                // Handle slash popup command selection
1342                if command.starts_with("__SLASH_INDEX_") {
1343                    if let Ok(idx) = command.trim_start_matches("__SLASH_INDEX_").parse::<usize>() {
1344                        self.execute_slash_command_at_index(idx);
1345                    }
1346                } else {
1347                    self.execute_command(&command);
1348                }
1349            }
1350            WidgetAction::Close => {
1351                // Widget closed itself (e.g., theme picker confirm/cancel)
1352            }
1353        }
1354    }
1355
1356    /// Check if the slash popup is active
1357    fn is_slash_popup_active(&self) -> bool {
1358        self.widgets
1359            .get(widget_ids::SLASH_POPUP)
1360            .map(|w| w.is_active())
1361            .unwrap_or(false)
1362    }
1363
1364    /// Filter commands by prefix and return indices into self.commands
1365    fn filter_command_indices(&self, prefix: &str) -> Vec<usize> {
1366        let search_term = prefix.trim_start_matches('/').to_lowercase();
1367        self.commands
1368            .iter()
1369            .enumerate()
1370            .filter(|(_, cmd)| cmd.name().to_lowercase().starts_with(&search_term))
1371            .map(|(i, _)| i)
1372            .collect()
1373    }
1374
1375    /// Activate the slash popup
1376    fn activate_slash_popup(&mut self) {
1377        // Filter first to avoid borrow issues
1378        let indices = self.filter_command_indices("/");
1379        let count = indices.len();
1380        self.filtered_command_indices = indices;
1381
1382        if let Some(widget) = self.widgets.get_mut(widget_ids::SLASH_POPUP) {
1383            if let Some(popup) = widget.as_any_mut().downcast_mut::<SlashPopupState>() {
1384                popup.activate();
1385                popup.set_filtered_count(count);
1386            }
1387        }
1388    }
1389
1390    /// Handle key events for the slash popup (needs special handling for input buffer)
1391    fn handle_slash_popup_key(&mut self, key: KeyCode) {
1392        match key {
1393            KeyCode::Up => {
1394                if let Some(widget) = self.widgets.get_mut(widget_ids::SLASH_POPUP) {
1395                    if let Some(popup) = widget.as_any_mut().downcast_mut::<SlashPopupState>() {
1396                        popup.select_previous();
1397                    }
1398                }
1399            }
1400            KeyCode::Down => {
1401                if let Some(widget) = self.widgets.get_mut(widget_ids::SLASH_POPUP) {
1402                    if let Some(popup) = widget.as_any_mut().downcast_mut::<SlashPopupState>() {
1403                        popup.select_next();
1404                    }
1405                }
1406            }
1407            KeyCode::Enter => {
1408                let selected_idx = self.widgets
1409                    .get(widget_ids::SLASH_POPUP)
1410                    .and_then(|w| w.as_any().downcast_ref::<SlashPopupState>())
1411                    .map(|p| p.selected_index)
1412                    .unwrap_or(0);
1413                self.execute_slash_command_at_index(selected_idx);
1414            }
1415            KeyCode::Esc => {
1416                if let Some(input) = self.input_mut() {
1417                    input.clear();
1418                }
1419                if let Some(widget) = self.widgets.get_mut(widget_ids::SLASH_POPUP) {
1420                    if let Some(popup) = widget.as_any_mut().downcast_mut::<SlashPopupState>() {
1421                        popup.deactivate();
1422                    }
1423                }
1424                self.filtered_command_indices.clear();
1425            }
1426            KeyCode::Backspace => {
1427                let is_just_slash = self.input().map(|i| i.buffer() == "/").unwrap_or(false);
1428                if is_just_slash {
1429                    if let Some(input) = self.input_mut() {
1430                        input.clear();
1431                    }
1432                    if let Some(widget) = self.widgets.get_mut(widget_ids::SLASH_POPUP) {
1433                        if let Some(popup) = widget.as_any_mut().downcast_mut::<SlashPopupState>() {
1434                            popup.deactivate();
1435                        }
1436                    }
1437                    self.filtered_command_indices.clear();
1438                } else {
1439                    if let Some(input) = self.input_mut() {
1440                        input.delete_char_before();
1441                    }
1442                    let buffer = self.input().map(|i| i.buffer().to_string()).unwrap_or_default();
1443                    self.filtered_command_indices = self.filter_command_indices(&buffer);
1444                    if let Some(widget) = self.widgets.get_mut(widget_ids::SLASH_POPUP) {
1445                        if let Some(popup) = widget.as_any_mut().downcast_mut::<SlashPopupState>() {
1446                            popup.set_filtered_count(self.filtered_command_indices.len());
1447                        }
1448                    }
1449                }
1450            }
1451            KeyCode::Char(c) => {
1452                if let Some(input) = self.input_mut() {
1453                    input.insert_char(c);
1454                }
1455                let buffer = self.input().map(|i| i.buffer().to_string()).unwrap_or_default();
1456                self.filtered_command_indices = self.filter_command_indices(&buffer);
1457                if let Some(widget) = self.widgets.get_mut(widget_ids::SLASH_POPUP) {
1458                    if let Some(popup) = widget.as_any_mut().downcast_mut::<SlashPopupState>() {
1459                        popup.set_filtered_count(self.filtered_command_indices.len());
1460                    }
1461                }
1462            }
1463            _ => {
1464                if let Some(widget) = self.widgets.get_mut(widget_ids::SLASH_POPUP) {
1465                    if let Some(popup) = widget.as_any_mut().downcast_mut::<SlashPopupState>() {
1466                        popup.deactivate();
1467                    }
1468                }
1469            }
1470        }
1471    }
1472
1473    /// Execute slash command at the given index in filtered list
1474    fn execute_slash_command_at_index(&mut self, idx: usize) {
1475        // Get the command index from the filtered list
1476        if let Some(&cmd_idx) = self.filtered_command_indices.get(idx) {
1477            if let Some(cmd) = self.commands.get(cmd_idx) {
1478                let cmd_name = cmd.name().to_string();
1479                if let Some(input) = self.input_mut() {
1480                    input.clear();
1481                    for c in format!("/{}", cmd_name).chars() {
1482                        input.insert_char(c);
1483                    }
1484                }
1485                if let Some(widget) = self.widgets.get_mut(widget_ids::SLASH_POPUP) {
1486                    if let Some(popup) = widget.as_any_mut().downcast_mut::<SlashPopupState>() {
1487                        popup.deactivate();
1488                    }
1489                }
1490                self.filtered_command_indices.clear();
1491                self.submit_message();
1492            }
1493        }
1494    }
1495
1496    pub fn run(&mut self) -> io::Result<()> {
1497        enable_raw_mode()?;
1498        io::stdout().execute(EnterAlternateScreen)?;
1499        io::stdout().execute(EnableMouseCapture)?;
1500
1501        let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?;
1502
1503        while !self.should_quit {
1504            self.process_controller_messages();
1505
1506            let show_throbber = self.waiting_for_response
1507                || self.is_chat_streaming()
1508                || !self.executing_tools.is_empty();
1509
1510            // Advance animations
1511            if show_throbber {
1512                self.animation_frame_counter = self.animation_frame_counter.wrapping_add(1);
1513                if self.animation_frame_counter % 6 == 0 {
1514                    self.throbber_state.calc_next();
1515                    self.conversation_view.step_spinner();
1516                }
1517            }
1518
1519            let prompt_len = PROMPT.chars().count();
1520            let indent_len = CONTINUATION_INDENT.len();
1521
1522            terminal.draw(|frame| {
1523                self.render_frame(frame, show_throbber, prompt_len, indent_len);
1524            })?;
1525
1526            // Event handling
1527            let mut net_scroll: i32 = 0;
1528
1529            while event::poll(std::time::Duration::from_millis(0))? {
1530                match event::read()? {
1531                    Event::Key(key) => {
1532                        if key.kind == KeyEventKind::Press {
1533                            self.handle_key(key.code, key.modifiers);
1534                        }
1535                    }
1536                    Event::Mouse(mouse) => match mouse.kind {
1537                        MouseEventKind::ScrollUp => net_scroll -= 1,
1538                        MouseEventKind::ScrollDown => net_scroll += 1,
1539                        _ => {}
1540                    },
1541                    _ => {}
1542                }
1543            }
1544
1545            // Apply scroll
1546            if net_scroll < 0 {
1547                for _ in 0..(-net_scroll) {
1548                    self.scroll_up();
1549                }
1550            } else if net_scroll > 0 {
1551                for _ in 0..net_scroll {
1552                    self.scroll_down();
1553                }
1554            }
1555
1556            if net_scroll == 0 {
1557                std::thread::sleep(std::time::Duration::from_millis(16));
1558            }
1559        }
1560
1561        io::stdout().execute(DisableMouseCapture)?;
1562        disable_raw_mode()?;
1563        io::stdout().execute(LeaveAlternateScreen)?;
1564
1565        Ok(())
1566    }
1567
1568    fn render_frame(
1569        &mut self,
1570        frame: &mut ratatui::Frame,
1571        show_throbber: bool,
1572        prompt_len: usize,
1573        indent_len: usize,
1574    ) {
1575        let frame_area = frame.area();
1576        let frame_width = frame_area.width as usize;
1577        let frame_height = frame_area.height;
1578        let theme = app_theme();
1579
1580        // Compute layout using the layout system
1581        let ctx = self.build_layout_context(frame_area, show_throbber, prompt_len, indent_len, &theme);
1582        let sizes = self.compute_widget_sizes(frame_height);
1583        let layout = self.layout_template.compute(&ctx, &sizes);
1584
1585        // Check overlay widget states (for cursor hiding)
1586        let theme_picker_active = sizes.is_active(widget_ids::THEME_PICKER);
1587        let session_picker_active = sizes.is_active(widget_ids::SESSION_PICKER);
1588        let question_panel_active = sizes.is_active(widget_ids::QUESTION_PANEL);
1589        let permission_panel_active = sizes.is_active(widget_ids::PERMISSION_PANEL);
1590        let batch_permission_panel_active = sizes.is_active(widget_ids::BATCH_PERMISSION_PANEL);
1591
1592        // Collect status bar data before taking mutable borrow
1593        let status_bar_data = StatusBarData {
1594            cwd: self.get_cwd(),
1595            model_name: self.model_name.clone(),
1596            context_used: self.context_used,
1597            context_limit: self.context_limit,
1598            session_id: self.session_id,
1599            status_hint: self.key_handler.status_hint(),
1600            is_waiting: show_throbber,
1601            waiting_elapsed: self.waiting_started.map(|t| t.elapsed()),
1602            input_empty: self.input().map(|i| i.is_empty()).unwrap_or(true),
1603            panels_active: question_panel_active || permission_panel_active || batch_permission_panel_active,
1604        };
1605
1606        // Update status bar with collected data
1607        if let Some(widget) = self.widgets.get_mut(widget_ids::STATUS_BAR) {
1608            if let Some(status_bar) = widget.as_any_mut().downcast_mut::<StatusBar>() {
1609                status_bar.update_data(status_bar_data);
1610            }
1611        }
1612
1613        // Render widgets in the order specified by the layout
1614        for widget_id in &layout.render_order {
1615            // Skip overlays (rendered last) and special widgets
1616            if *widget_id == widget_ids::THEME_PICKER || *widget_id == widget_ids::SESSION_PICKER {
1617                continue;
1618            }
1619
1620            // Get the area for this widget
1621            let Some(area) = layout.widget_areas.get(widget_id) else {
1622                continue;
1623            };
1624
1625            // Handle special widgets that need custom rendering
1626            match *widget_id {
1627                id if id == widget_ids::CHAT_VIEW => {
1628                    // Conversation view is rendered via the trait, not as a widget
1629                    let pending_status: Option<&str> = if !self.executing_tools.is_empty() {
1630                        Some(PENDING_STATUS_TOOLS)
1631                    } else if self.waiting_for_response && !self.is_chat_streaming() {
1632                        Some(PENDING_STATUS_LLM)
1633                    } else {
1634                        None
1635                    };
1636                    self.conversation_view.render(frame, *area, &theme, pending_status);
1637                }
1638                id if id == widget_ids::TEXT_INPUT => {
1639                    // Input is rendered specially below (with throbber logic)
1640                }
1641                id if id == widget_ids::SLASH_POPUP => {
1642                    if let Some(widget) = self.widgets.get(widget_ids::SLASH_POPUP) {
1643                        if let Some(popup_state) = widget.as_any().downcast_ref::<SlashPopupState>() {
1644                            // Build filtered commands from indices
1645                            let filtered: Vec<&dyn SlashCommand> = self.filtered_command_indices
1646                                .iter()
1647                                .filter_map(|&i| self.commands.get(i).map(|c| c.as_ref()))
1648                                .collect();
1649                            render_slash_popup(
1650                                popup_state,
1651                                &filtered,
1652                                frame,
1653                                *area,
1654                                &theme,
1655                            );
1656                        }
1657                    }
1658                }
1659                _ => {
1660                    // Generic widget rendering
1661                    if let Some(widget) = self.widgets.get_mut(widget_id) {
1662                        if widget.is_active() {
1663                            widget.render(frame, *area, &theme);
1664                        }
1665                    }
1666                }
1667            }
1668        }
1669
1670        // Render input or throbber (special handling)
1671        if let Some(input_area) = layout.input_area {
1672            if !question_panel_active && !permission_panel_active && !batch_permission_panel_active {
1673                if show_throbber {
1674                    let default_message;
1675                    let message = if let Some(msg) = &self.custom_throbber_message {
1676                        msg.as_str()
1677                    } else if let Some(ref msg_fn) = self.processing_message_fn {
1678                        default_message = msg_fn();
1679                        &default_message
1680                    } else {
1681                        &self.processing_message
1682                    };
1683                    let throbber = Throbber::default()
1684                        .label(message)
1685                        .style(theme.throbber_label)
1686                        .throbber_style(theme.throbber_spinner)
1687                        .throbber_set(BRAILLE_EIGHT_DOUBLE);
1688
1689                    let throbber_block = Block::default()
1690                        .borders(Borders::TOP | Borders::BOTTOM)
1691                        .border_style(theme.input_border);
1692                    let inner = throbber_block.inner(input_area);
1693                    let throbber_inner = Rect::new(
1694                        inner.x + 1,
1695                        inner.y,
1696                        inner.width.saturating_sub(1),
1697                        inner.height,
1698                    );
1699                    frame.render_widget(throbber_block, input_area);
1700                    frame.render_stateful_widget(throbber, throbber_inner, &mut self.throbber_state);
1701                } else if let Some(input) = self.input() {
1702                    let input_lines: Vec<String> = input
1703                        .buffer()
1704                        .split('\n')
1705                        .enumerate()
1706                        .map(|(i, line)| {
1707                            if i == 0 {
1708                                format!("{}{}", PROMPT, line)
1709                            } else {
1710                                format!("{}{}", CONTINUATION_INDENT, line)
1711                            }
1712                        })
1713                        .collect();
1714                    let input_text = if input_lines.is_empty() {
1715                        PROMPT.to_string()
1716                    } else {
1717                        input_lines.join("\n")
1718                    };
1719
1720                    let input_box = Paragraph::new(input_text)
1721                        .block(
1722                            Block::default()
1723                                .borders(Borders::TOP | Borders::BOTTOM)
1724                                .border_style(theme.input_border),
1725                        )
1726                        .wrap(Wrap { trim: false });
1727                    frame.render_widget(input_box, input_area);
1728
1729                    // Only show cursor if no overlay is active
1730                    if !theme_picker_active && !session_picker_active {
1731                        let (cursor_rel_x, cursor_rel_y) = input
1732                            .cursor_display_position_wrapped(frame_width, prompt_len, indent_len);
1733                        let cursor_x = input_area.x + cursor_rel_x;
1734                        let cursor_y = input_area.y + 1 + cursor_rel_y;
1735                        frame.set_cursor_position((cursor_x, cursor_y));
1736                    }
1737                }
1738            }
1739        }
1740
1741
1742        // Render overlay widgets (theme picker, session picker) - always on top
1743        if theme_picker_active {
1744            if let Some(widget) = self.widgets.get(widget_ids::THEME_PICKER) {
1745                if let Some(picker) = widget.as_any().downcast_ref::<ThemePickerState>() {
1746                    render_theme_picker(picker, frame, frame_area);
1747                }
1748            }
1749        }
1750
1751        if session_picker_active {
1752            if let Some(widget) = self.widgets.get(widget_ids::SESSION_PICKER) {
1753                if let Some(picker) = widget.as_any().downcast_ref::<SessionPickerState>() {
1754                    render_session_picker(picker, frame, frame_area, &theme);
1755                }
1756            }
1757        }
1758    }
1759}
1760
1761impl Default for App {
1762    fn default() -> Self {
1763        Self::new()
1764    }
1765}