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, PermissionRegistry, PermissionResponse,
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,
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: PermissionResponse) {
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(&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    /// Process any pending messages from the controller
904    fn process_controller_messages(&mut self) {
905        // Collect all available messages first to avoid borrow issues
906        let mut messages = Vec::new();
907
908        if let Some(ref mut rx) = self.from_controller {
909            loop {
910                match rx.try_recv() {
911                    Ok(msg) => messages.push(msg),
912                    Err(mpsc::error::TryRecvError::Empty) => break,
913                    Err(mpsc::error::TryRecvError::Disconnected) => {
914                        tracing::warn!("Controller channel disconnected");
915                        break;
916                    }
917                }
918            }
919        }
920
921        // Process collected messages
922        for msg in messages {
923            self.handle_ui_message(msg);
924        }
925    }
926
927    /// Handle a UI message from the controller
928    fn handle_ui_message(&mut self, msg: UiMessage) {
929        match msg {
930            UiMessage::TextChunk { text, turn_id, .. } => {
931                // Filter stale messages from cancelled requests
932                if !self.is_current_turn(&turn_id) {
933                    return;
934                }
935                self.conversation_view.append_streaming(&text);
936            }
937            UiMessage::Display { message, .. } => {
938                self.conversation_view.add_system_message(message);
939            }
940            UiMessage::Complete {
941                turn_id,
942                stop_reason,
943                ..
944            } => {
945                // Filter stale Complete messages from cancelled requests
946                if !self.is_current_turn(&turn_id) {
947                    return;
948                }
949
950                // Check if this is a tool_use stop - if so, tools will execute
951                let is_tool_use = stop_reason.as_deref() == Some("tool_use");
952
953                self.conversation_view.complete_streaming();
954
955                // Only stop waiting if this is NOT a tool_use stop
956                if !is_tool_use {
957                    self.waiting_for_response = false;
958                    self.waiting_started = None;
959                }
960            }
961            UiMessage::TokenUpdate {
962                input_tokens,
963                context_limit,
964                ..
965            } => {
966                self.context_used = input_tokens;
967                self.context_limit = context_limit;
968            }
969            UiMessage::Error { error, turn_id, .. } => {
970                if !self.is_current_turn(&turn_id) {
971                    return;
972                }
973                self.conversation_view.complete_streaming();
974                self.waiting_for_response = false;
975                self.waiting_started = None;
976                self.current_turn_id = None;
977                self.conversation_view.add_system_message(format!("Error: {}", error));
978            }
979            UiMessage::System { message, .. } => {
980                self.conversation_view.add_system_message(message);
981            }
982            UiMessage::ToolExecuting {
983                tool_use_id,
984                display_name,
985                display_title,
986                ..
987            } => {
988                self.executing_tools.insert(tool_use_id.clone());
989                self.conversation_view.add_tool_message(&tool_use_id, &display_name, &display_title);
990            }
991            UiMessage::ToolCompleted {
992                tool_use_id,
993                status,
994                error,
995                ..
996            } => {
997                self.executing_tools.remove(&tool_use_id);
998                let tool_status = if status == ToolResultStatus::Success {
999                    ToolStatus::Completed
1000                } else {
1001                    ToolStatus::Failed(error.unwrap_or_default())
1002                };
1003                self.conversation_view.update_tool_status(&tool_use_id, tool_status);
1004            }
1005            UiMessage::CommandComplete {
1006                command,
1007                success,
1008                message,
1009                ..
1010            } => {
1011                self.waiting_for_response = false;
1012                self.waiting_started = None;
1013                self.custom_throbber_message = None;
1014
1015                match command {
1016                    ControlCmd::Compact => {
1017                        if let Some(msg) = message {
1018                            self.conversation_view.add_system_message(msg);
1019                        }
1020                    }
1021                    ControlCmd::Clear => {}
1022                    _ => {
1023                        tracing::debug!(?command, ?success, "Command completed");
1024                    }
1025                }
1026            }
1027            UiMessage::UserInteractionRequired {
1028                session_id,
1029                tool_use_id,
1030                request,
1031                turn_id,
1032            } => {
1033                if session_id == self.session_id {
1034                    self.conversation_view.update_tool_status(&tool_use_id, ToolStatus::WaitingForUser);
1035                    // Activate via widget registry if registered
1036                    if let Some(widget) = self.widgets.get_mut(widget_ids::QUESTION_PANEL) {
1037                        if let Some(panel) = widget.as_any_mut().downcast_mut::<QuestionPanel>() {
1038                            panel.activate(tool_use_id, session_id, request, turn_id);
1039                        }
1040                    }
1041                }
1042            }
1043            UiMessage::PermissionRequired {
1044                session_id,
1045                tool_use_id,
1046                request,
1047                turn_id,
1048            } => {
1049                if session_id == self.session_id {
1050                    self.conversation_view.update_tool_status(&tool_use_id, ToolStatus::WaitingForUser);
1051                    // Activate via widget registry if registered
1052                    if let Some(widget) = self.widgets.get_mut(widget_ids::PERMISSION_PANEL) {
1053                        if let Some(panel) = widget.as_any_mut().downcast_mut::<PermissionPanel>() {
1054                            panel.activate(tool_use_id, session_id, request, turn_id);
1055                        }
1056                    }
1057                }
1058            }
1059        }
1060    }
1061
1062    /// Check if the given turn_id matches the current expected turn
1063    fn is_current_turn(&self, turn_id: &Option<TurnId>) -> bool {
1064        match (&self.current_turn_id, turn_id) {
1065            (Some(current), Some(incoming)) => current == incoming,
1066            (None, _) => false,
1067            (Some(_), None) => false,
1068        }
1069    }
1070
1071    pub fn scroll_up(&mut self) {
1072        self.conversation_view.scroll_up();
1073    }
1074
1075    pub fn scroll_down(&mut self) {
1076        self.conversation_view.scroll_down();
1077    }
1078
1079    /// Get the current working directory with home directory substitution
1080    fn get_cwd(&self) -> String {
1081        std::env::current_dir()
1082            .map(|p| {
1083                let path_str = p.display().to_string();
1084                if let Some(home) = std::env::var_os("HOME") {
1085                    let home_str = home.to_string_lossy();
1086                    if path_str.starts_with(home_str.as_ref()) {
1087                        return format!("~{}", &path_str[home_str.len()..]);
1088                    }
1089                }
1090                path_str
1091            })
1092            .unwrap_or_else(|_| "unknown".to_string())
1093    }
1094
1095    /// Handle key events
1096    fn handle_key(&mut self, key: KeyCode, modifiers: KeyModifiers) {
1097        // Build key context for the handler
1098        let key_event = KeyEvent::new(key, modifiers);
1099        let context = KeyContext {
1100            input_empty: self.input().map(|i| i.is_empty()).unwrap_or(true),
1101            is_processing: self.waiting_for_response || self.is_chat_streaming(),
1102            widget_blocking: self.any_widget_blocks_input(),
1103        };
1104
1105        // Let the key handler process first
1106        // (handler manages exit confirmation state internally)
1107        let result = self.key_handler.handle_key(key_event, &context);
1108
1109        match result {
1110            AppKeyResult::Handled => return,
1111            AppKeyResult::Action(action) => {
1112                self.execute_key_action(action);
1113                return;
1114            }
1115            AppKeyResult::NotHandled => {
1116                // Continue to widget dispatch
1117            }
1118        }
1119
1120        // Try to send key event to registered widgets (by priority order)
1121        let theme = app_theme();
1122        let nav = NavigationHelper::new(self.key_handler.bindings());
1123        let widget_ctx = WidgetKeyContext { theme: &theme, nav };
1124
1125        // Collect widget IDs to check (we need to avoid borrow issues)
1126        let widget_ids_to_check: Vec<&'static str> = self.widget_priority_order.clone();
1127
1128        for widget_id in widget_ids_to_check {
1129            if let Some(widget) = self.widgets.get_mut(widget_id) {
1130                if widget.is_active() {
1131                    match widget.handle_key(key_event, &widget_ctx) {
1132                        WidgetKeyResult::Handled => return,
1133                        WidgetKeyResult::Action(action) => {
1134                            self.process_widget_action(action);
1135                            return;
1136                        }
1137                        WidgetKeyResult::NotHandled => {
1138                            // Continue to next widget or fall through to input handling
1139                        }
1140                    }
1141                }
1142            }
1143        }
1144
1145        // Handle slash popup specially (needs input buffer access)
1146        if self.is_slash_popup_active() {
1147            self.handle_slash_popup_key(key);
1148            return;
1149        }
1150    }
1151
1152    /// Execute an application-level key action.
1153    fn execute_key_action(&mut self, action: AppKeyAction) {
1154        match action {
1155            AppKeyAction::MoveUp => {
1156                if let Some(input) = self.input_mut() {
1157                    input.move_up();
1158                }
1159            }
1160            AppKeyAction::MoveDown => {
1161                if let Some(input) = self.input_mut() {
1162                    input.move_down();
1163                }
1164            }
1165            AppKeyAction::MoveLeft => {
1166                if let Some(input) = self.input_mut() {
1167                    input.move_left();
1168                }
1169            }
1170            AppKeyAction::MoveRight => {
1171                if let Some(input) = self.input_mut() {
1172                    input.move_right();
1173                }
1174            }
1175            AppKeyAction::MoveLineStart => {
1176                if let Some(input) = self.input_mut() {
1177                    input.move_to_line_start();
1178                }
1179            }
1180            AppKeyAction::MoveLineEnd => {
1181                if let Some(input) = self.input_mut() {
1182                    input.move_to_line_end();
1183                }
1184            }
1185            AppKeyAction::DeleteCharBefore => {
1186                if let Some(input) = self.input_mut() {
1187                    input.delete_char_before();
1188                }
1189            }
1190            AppKeyAction::DeleteCharAt => {
1191                if let Some(input) = self.input_mut() {
1192                    input.delete_char_at();
1193                }
1194            }
1195            AppKeyAction::KillLine => {
1196                if let Some(input) = self.input_mut() {
1197                    input.kill_line();
1198                }
1199            }
1200            AppKeyAction::InsertNewline => {
1201                if let Some(input) = self.input_mut() {
1202                    input.insert_char('\n');
1203                }
1204            }
1205            AppKeyAction::InsertChar(c) => {
1206                if let Some(input) = self.input_mut() {
1207                    input.insert_char(c);
1208                }
1209                // Check if we just typed "/" as the first character for slash popup
1210                if c == '/' && self.input().map(|i| i.buffer() == "/").unwrap_or(false) {
1211                    self.activate_slash_popup();
1212                }
1213            }
1214            AppKeyAction::Submit => {
1215                self.submit_message();
1216            }
1217            AppKeyAction::Interrupt => {
1218                self.interrupt_request();
1219            }
1220            AppKeyAction::Quit => {
1221                self.should_quit = true;
1222            }
1223            AppKeyAction::RequestExit => {
1224                // Call exit handler if set, otherwise just quit
1225                let should_quit = self.exit_handler
1226                    .as_mut()
1227                    .map(|h| h.on_exit())
1228                    .unwrap_or(true);
1229                if should_quit {
1230                    self.should_quit = true;
1231                }
1232            }
1233            AppKeyAction::ActivateSlashPopup => {
1234                self.activate_slash_popup();
1235            }
1236            AppKeyAction::Custom(_) => {
1237                // Custom actions are handled by the custom_action_handler if set.
1238                // Users implementing custom key bindings should set a handler
1239                // via with_custom_action_handler() to receive these actions.
1240            }
1241        }
1242    }
1243
1244    /// Process an action returned by a widget
1245    fn process_widget_action(&mut self, action: WidgetAction) {
1246        match action {
1247            WidgetAction::SubmitQuestion { tool_use_id, response } => {
1248                self.submit_question_panel_response(tool_use_id, response);
1249            }
1250            WidgetAction::CancelQuestion { tool_use_id } => {
1251                self.cancel_question_panel_response(tool_use_id);
1252            }
1253            WidgetAction::SubmitPermission { tool_use_id, response } => {
1254                self.submit_permission_panel_response(tool_use_id, response);
1255            }
1256            WidgetAction::CancelPermission { tool_use_id } => {
1257                self.cancel_permission_panel_response(tool_use_id);
1258            }
1259            WidgetAction::SwitchSession { session_id } => {
1260                self.switch_session(session_id);
1261            }
1262            WidgetAction::ExecuteCommand { command } => {
1263                // Handle slash popup command selection
1264                if command.starts_with("__SLASH_INDEX_") {
1265                    if let Ok(idx) = command.trim_start_matches("__SLASH_INDEX_").parse::<usize>() {
1266                        self.execute_slash_command_at_index(idx);
1267                    }
1268                } else {
1269                    self.execute_command(&command);
1270                }
1271            }
1272            WidgetAction::Close => {
1273                // Widget closed itself (e.g., theme picker confirm/cancel)
1274            }
1275        }
1276    }
1277
1278    /// Check if the slash popup is active
1279    fn is_slash_popup_active(&self) -> bool {
1280        self.widgets
1281            .get(widget_ids::SLASH_POPUP)
1282            .map(|w| w.is_active())
1283            .unwrap_or(false)
1284    }
1285
1286    /// Filter commands by prefix and return indices into self.commands
1287    fn filter_command_indices(&self, prefix: &str) -> Vec<usize> {
1288        let search_term = prefix.trim_start_matches('/').to_lowercase();
1289        self.commands
1290            .iter()
1291            .enumerate()
1292            .filter(|(_, cmd)| cmd.name().to_lowercase().starts_with(&search_term))
1293            .map(|(i, _)| i)
1294            .collect()
1295    }
1296
1297    /// Activate the slash popup
1298    fn activate_slash_popup(&mut self) {
1299        // Filter first to avoid borrow issues
1300        let indices = self.filter_command_indices("/");
1301        let count = indices.len();
1302        self.filtered_command_indices = indices;
1303
1304        if let Some(widget) = self.widgets.get_mut(widget_ids::SLASH_POPUP) {
1305            if let Some(popup) = widget.as_any_mut().downcast_mut::<SlashPopupState>() {
1306                popup.activate();
1307                popup.set_filtered_count(count);
1308            }
1309        }
1310    }
1311
1312    /// Handle key events for the slash popup (needs special handling for input buffer)
1313    fn handle_slash_popup_key(&mut self, key: KeyCode) {
1314        match key {
1315            KeyCode::Up => {
1316                if let Some(widget) = self.widgets.get_mut(widget_ids::SLASH_POPUP) {
1317                    if let Some(popup) = widget.as_any_mut().downcast_mut::<SlashPopupState>() {
1318                        popup.select_previous();
1319                    }
1320                }
1321            }
1322            KeyCode::Down => {
1323                if let Some(widget) = self.widgets.get_mut(widget_ids::SLASH_POPUP) {
1324                    if let Some(popup) = widget.as_any_mut().downcast_mut::<SlashPopupState>() {
1325                        popup.select_next();
1326                    }
1327                }
1328            }
1329            KeyCode::Enter => {
1330                let selected_idx = self.widgets
1331                    .get(widget_ids::SLASH_POPUP)
1332                    .and_then(|w| w.as_any().downcast_ref::<SlashPopupState>())
1333                    .map(|p| p.selected_index)
1334                    .unwrap_or(0);
1335                self.execute_slash_command_at_index(selected_idx);
1336            }
1337            KeyCode::Esc => {
1338                if let Some(input) = self.input_mut() {
1339                    input.clear();
1340                }
1341                if let Some(widget) = self.widgets.get_mut(widget_ids::SLASH_POPUP) {
1342                    if let Some(popup) = widget.as_any_mut().downcast_mut::<SlashPopupState>() {
1343                        popup.deactivate();
1344                    }
1345                }
1346                self.filtered_command_indices.clear();
1347            }
1348            KeyCode::Backspace => {
1349                let is_just_slash = self.input().map(|i| i.buffer() == "/").unwrap_or(false);
1350                if is_just_slash {
1351                    if let Some(input) = self.input_mut() {
1352                        input.clear();
1353                    }
1354                    if let Some(widget) = self.widgets.get_mut(widget_ids::SLASH_POPUP) {
1355                        if let Some(popup) = widget.as_any_mut().downcast_mut::<SlashPopupState>() {
1356                            popup.deactivate();
1357                        }
1358                    }
1359                    self.filtered_command_indices.clear();
1360                } else {
1361                    if let Some(input) = self.input_mut() {
1362                        input.delete_char_before();
1363                    }
1364                    let buffer = self.input().map(|i| i.buffer().to_string()).unwrap_or_default();
1365                    self.filtered_command_indices = self.filter_command_indices(&buffer);
1366                    if let Some(widget) = self.widgets.get_mut(widget_ids::SLASH_POPUP) {
1367                        if let Some(popup) = widget.as_any_mut().downcast_mut::<SlashPopupState>() {
1368                            popup.set_filtered_count(self.filtered_command_indices.len());
1369                        }
1370                    }
1371                }
1372            }
1373            KeyCode::Char(c) => {
1374                if let Some(input) = self.input_mut() {
1375                    input.insert_char(c);
1376                }
1377                let buffer = self.input().map(|i| i.buffer().to_string()).unwrap_or_default();
1378                self.filtered_command_indices = self.filter_command_indices(&buffer);
1379                if let Some(widget) = self.widgets.get_mut(widget_ids::SLASH_POPUP) {
1380                    if let Some(popup) = widget.as_any_mut().downcast_mut::<SlashPopupState>() {
1381                        popup.set_filtered_count(self.filtered_command_indices.len());
1382                    }
1383                }
1384            }
1385            _ => {
1386                if let Some(widget) = self.widgets.get_mut(widget_ids::SLASH_POPUP) {
1387                    if let Some(popup) = widget.as_any_mut().downcast_mut::<SlashPopupState>() {
1388                        popup.deactivate();
1389                    }
1390                }
1391            }
1392        }
1393    }
1394
1395    /// Execute slash command at the given index in filtered list
1396    fn execute_slash_command_at_index(&mut self, idx: usize) {
1397        // Get the command index from the filtered list
1398        if let Some(&cmd_idx) = self.filtered_command_indices.get(idx) {
1399            if let Some(cmd) = self.commands.get(cmd_idx) {
1400                let cmd_name = cmd.name().to_string();
1401                if let Some(input) = self.input_mut() {
1402                    input.clear();
1403                    for c in format!("/{}", cmd_name).chars() {
1404                        input.insert_char(c);
1405                    }
1406                }
1407                if let Some(widget) = self.widgets.get_mut(widget_ids::SLASH_POPUP) {
1408                    if let Some(popup) = widget.as_any_mut().downcast_mut::<SlashPopupState>() {
1409                        popup.deactivate();
1410                    }
1411                }
1412                self.filtered_command_indices.clear();
1413                self.submit_message();
1414            }
1415        }
1416    }
1417
1418    pub fn run(&mut self) -> io::Result<()> {
1419        enable_raw_mode()?;
1420        io::stdout().execute(EnterAlternateScreen)?;
1421        io::stdout().execute(EnableMouseCapture)?;
1422
1423        let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?;
1424
1425        while !self.should_quit {
1426            self.process_controller_messages();
1427
1428            let show_throbber = self.waiting_for_response
1429                || self.is_chat_streaming()
1430                || !self.executing_tools.is_empty();
1431
1432            // Advance animations
1433            if show_throbber {
1434                self.animation_frame_counter = self.animation_frame_counter.wrapping_add(1);
1435                if self.animation_frame_counter % 6 == 0 {
1436                    self.throbber_state.calc_next();
1437                    self.conversation_view.step_spinner();
1438                }
1439            }
1440
1441            let prompt_len = PROMPT.chars().count();
1442            let indent_len = CONTINUATION_INDENT.len();
1443
1444            terminal.draw(|frame| {
1445                self.render_frame(frame, show_throbber, prompt_len, indent_len);
1446            })?;
1447
1448            // Event handling
1449            let mut net_scroll: i32 = 0;
1450
1451            while event::poll(std::time::Duration::from_millis(0))? {
1452                match event::read()? {
1453                    Event::Key(key) => {
1454                        if key.kind == KeyEventKind::Press {
1455                            self.handle_key(key.code, key.modifiers);
1456                        }
1457                    }
1458                    Event::Mouse(mouse) => match mouse.kind {
1459                        MouseEventKind::ScrollUp => net_scroll -= 1,
1460                        MouseEventKind::ScrollDown => net_scroll += 1,
1461                        _ => {}
1462                    },
1463                    _ => {}
1464                }
1465            }
1466
1467            // Apply scroll
1468            if net_scroll < 0 {
1469                for _ in 0..(-net_scroll) {
1470                    self.scroll_up();
1471                }
1472            } else if net_scroll > 0 {
1473                for _ in 0..net_scroll {
1474                    self.scroll_down();
1475                }
1476            }
1477
1478            if net_scroll == 0 {
1479                std::thread::sleep(std::time::Duration::from_millis(16));
1480            }
1481        }
1482
1483        io::stdout().execute(DisableMouseCapture)?;
1484        disable_raw_mode()?;
1485        io::stdout().execute(LeaveAlternateScreen)?;
1486
1487        Ok(())
1488    }
1489
1490    fn render_frame(
1491        &mut self,
1492        frame: &mut ratatui::Frame,
1493        show_throbber: bool,
1494        prompt_len: usize,
1495        indent_len: usize,
1496    ) {
1497        let frame_area = frame.area();
1498        let frame_width = frame_area.width as usize;
1499        let frame_height = frame_area.height;
1500        let theme = app_theme();
1501
1502        // Compute layout using the layout system
1503        let ctx = self.build_layout_context(frame_area, show_throbber, prompt_len, indent_len, &theme);
1504        let sizes = self.compute_widget_sizes(frame_height);
1505        let layout = self.layout_template.compute(&ctx, &sizes);
1506
1507        // Check overlay widget states (for cursor hiding)
1508        let theme_picker_active = sizes.is_active(widget_ids::THEME_PICKER);
1509        let session_picker_active = sizes.is_active(widget_ids::SESSION_PICKER);
1510        let question_panel_active = sizes.is_active(widget_ids::QUESTION_PANEL);
1511        let permission_panel_active = sizes.is_active(widget_ids::PERMISSION_PANEL);
1512
1513        // Collect status bar data before taking mutable borrow
1514        let status_bar_data = StatusBarData {
1515            cwd: self.get_cwd(),
1516            model_name: self.model_name.clone(),
1517            context_used: self.context_used,
1518            context_limit: self.context_limit,
1519            session_id: self.session_id,
1520            status_hint: self.key_handler.status_hint(),
1521            is_waiting: show_throbber,
1522            waiting_elapsed: self.waiting_started.map(|t| t.elapsed()),
1523            input_empty: self.input().map(|i| i.is_empty()).unwrap_or(true),
1524            panels_active: question_panel_active || permission_panel_active,
1525        };
1526
1527        // Update status bar with collected data
1528        if let Some(widget) = self.widgets.get_mut(widget_ids::STATUS_BAR) {
1529            if let Some(status_bar) = widget.as_any_mut().downcast_mut::<StatusBar>() {
1530                status_bar.update_data(status_bar_data);
1531            }
1532        }
1533
1534        // Render widgets in the order specified by the layout
1535        for widget_id in &layout.render_order {
1536            // Skip overlays (rendered last) and special widgets
1537            if *widget_id == widget_ids::THEME_PICKER || *widget_id == widget_ids::SESSION_PICKER {
1538                continue;
1539            }
1540
1541            // Get the area for this widget
1542            let Some(area) = layout.widget_areas.get(widget_id) else {
1543                continue;
1544            };
1545
1546            // Handle special widgets that need custom rendering
1547            match *widget_id {
1548                id if id == widget_ids::CHAT_VIEW => {
1549                    // Conversation view is rendered via the trait, not as a widget
1550                    let pending_status: Option<&str> = if !self.executing_tools.is_empty() {
1551                        Some(PENDING_STATUS_TOOLS)
1552                    } else if self.waiting_for_response && !self.is_chat_streaming() {
1553                        Some(PENDING_STATUS_LLM)
1554                    } else {
1555                        None
1556                    };
1557                    self.conversation_view.render(frame, *area, &theme, pending_status);
1558                }
1559                id if id == widget_ids::TEXT_INPUT => {
1560                    // Input is rendered specially below (with throbber logic)
1561                }
1562                id if id == widget_ids::SLASH_POPUP => {
1563                    if let Some(widget) = self.widgets.get(widget_ids::SLASH_POPUP) {
1564                        if let Some(popup_state) = widget.as_any().downcast_ref::<SlashPopupState>() {
1565                            // Build filtered commands from indices
1566                            let filtered: Vec<&dyn SlashCommand> = self.filtered_command_indices
1567                                .iter()
1568                                .filter_map(|&i| self.commands.get(i).map(|c| c.as_ref()))
1569                                .collect();
1570                            render_slash_popup(
1571                                popup_state,
1572                                &filtered,
1573                                frame,
1574                                *area,
1575                                &theme,
1576                            );
1577                        }
1578                    }
1579                }
1580                _ => {
1581                    // Generic widget rendering
1582                    if let Some(widget) = self.widgets.get_mut(widget_id) {
1583                        if widget.is_active() {
1584                            widget.render(frame, *area, &theme);
1585                        }
1586                    }
1587                }
1588            }
1589        }
1590
1591        // Render input or throbber (special handling)
1592        if let Some(input_area) = layout.input_area {
1593            if !question_panel_active && !permission_panel_active {
1594                if show_throbber {
1595                    let default_message;
1596                    let message = if let Some(msg) = &self.custom_throbber_message {
1597                        msg.as_str()
1598                    } else if let Some(ref msg_fn) = self.processing_message_fn {
1599                        default_message = msg_fn();
1600                        &default_message
1601                    } else {
1602                        &self.processing_message
1603                    };
1604                    let throbber = Throbber::default()
1605                        .label(message)
1606                        .style(theme.throbber_label)
1607                        .throbber_style(theme.throbber_spinner)
1608                        .throbber_set(BRAILLE_EIGHT_DOUBLE);
1609
1610                    let throbber_block = Block::default()
1611                        .borders(Borders::TOP | Borders::BOTTOM)
1612                        .border_style(theme.input_border);
1613                    let inner = throbber_block.inner(input_area);
1614                    let throbber_inner = Rect::new(
1615                        inner.x + 1,
1616                        inner.y,
1617                        inner.width.saturating_sub(1),
1618                        inner.height,
1619                    );
1620                    frame.render_widget(throbber_block, input_area);
1621                    frame.render_stateful_widget(throbber, throbber_inner, &mut self.throbber_state);
1622                } else if let Some(input) = self.input() {
1623                    let input_lines: Vec<String> = input
1624                        .buffer()
1625                        .split('\n')
1626                        .enumerate()
1627                        .map(|(i, line)| {
1628                            if i == 0 {
1629                                format!("{}{}", PROMPT, line)
1630                            } else {
1631                                format!("{}{}", CONTINUATION_INDENT, line)
1632                            }
1633                        })
1634                        .collect();
1635                    let input_text = if input_lines.is_empty() {
1636                        PROMPT.to_string()
1637                    } else {
1638                        input_lines.join("\n")
1639                    };
1640
1641                    let input_box = Paragraph::new(input_text)
1642                        .block(
1643                            Block::default()
1644                                .borders(Borders::TOP | Borders::BOTTOM)
1645                                .border_style(theme.input_border),
1646                        )
1647                        .wrap(Wrap { trim: false });
1648                    frame.render_widget(input_box, input_area);
1649
1650                    // Only show cursor if no overlay is active
1651                    if !theme_picker_active && !session_picker_active {
1652                        let (cursor_rel_x, cursor_rel_y) = input
1653                            .cursor_display_position_wrapped(frame_width, prompt_len, indent_len);
1654                        let cursor_x = input_area.x + cursor_rel_x;
1655                        let cursor_y = input_area.y + 1 + cursor_rel_y;
1656                        frame.set_cursor_position((cursor_x, cursor_y));
1657                    }
1658                }
1659            }
1660        }
1661
1662
1663        // Render overlay widgets (theme picker, session picker) - always on top
1664        if theme_picker_active {
1665            if let Some(widget) = self.widgets.get(widget_ids::THEME_PICKER) {
1666                if let Some(picker) = widget.as_any().downcast_ref::<ThemePickerState>() {
1667                    render_theme_picker(picker, frame, frame_area);
1668                }
1669            }
1670        }
1671
1672        if session_picker_active {
1673            if let Some(widget) = self.widgets.get(widget_ids::SESSION_PICKER) {
1674                if let Some(picker) = widget.as_any().downcast_ref::<SessionPickerState>() {
1675                    render_session_picker(picker, frame, frame_area, &theme);
1676                }
1677            }
1678        }
1679    }
1680}
1681
1682impl Default for App {
1683    fn default() -> Self {
1684        Self::new()
1685    }
1686}