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