use anyhow::Result; use owlen_core::mcp::remote_client::RemoteMcpClient; use owlen_core::{ provider::{Provider, ProviderConfig}, session::{SessionController, SessionOutcome}, storage::SessionMeta, theme::Theme, types::{ChatParameters, ChatResponse, Conversation, ModelInfo, Role}, ui::{AppState, AutoScroll, FocusedPanel, InputMode}, }; use ratatui::style::{Color, Modifier, Style}; use tokio::sync::mpsc; use tui_textarea::{Input, TextArea}; use uuid::Uuid; use crate::config; use crate::events::Event; // Agent executor moved to separate binary `owlen-agent`. The TUI no longer directly // imports `AgentExecutor` to avoid a circular dependency on `owlen-cli`. use std::collections::{BTreeSet, HashSet}; use std::sync::Arc; #[derive(Clone, Debug)] pub(crate) struct ModelSelectorItem { kind: ModelSelectorItemKind, } #[derive(Clone, Debug)] pub(crate) enum ModelSelectorItemKind { Header { provider: String, expanded: bool, }, Model { provider: String, model_index: usize, }, Empty { provider: String, }, } impl ModelSelectorItem { fn header(provider: impl Into, expanded: bool) -> Self { Self { kind: ModelSelectorItemKind::Header { provider: provider.into(), expanded, }, } } fn model(provider: impl Into, model_index: usize) -> Self { Self { kind: ModelSelectorItemKind::Model { provider: provider.into(), model_index, }, } } fn empty(provider: impl Into) -> Self { Self { kind: ModelSelectorItemKind::Empty { provider: provider.into(), }, } } fn is_model(&self) -> bool { matches!(self.kind, ModelSelectorItemKind::Model { .. }) } fn model_index(&self) -> Option { match &self.kind { ModelSelectorItemKind::Model { model_index, .. } => Some(*model_index), _ => None, } } fn provider_if_header(&self) -> Option<&str> { match &self.kind { ModelSelectorItemKind::Header { provider, .. } => Some(provider), _ => None, } } pub(crate) fn kind(&self) -> &ModelSelectorItemKind { &self.kind } } /// Messages emitted by asynchronous streaming tasks #[derive(Debug)] pub enum SessionEvent { StreamChunk { message_id: Uuid, response: ChatResponse, }, StreamError { message: String, }, ToolExecutionNeeded { message_id: Uuid, tool_calls: Vec, }, ConsentNeeded { tool_name: String, data_types: Vec, endpoints: Vec, callback_id: Uuid, }, /// Agent iteration update (shows THOUGHT/ACTION/OBSERVATION) AgentUpdate { content: String, }, /// Agent execution completed with final answer AgentCompleted { answer: String, }, /// Agent execution failed AgentFailed { error: String, }, } pub const HELP_TAB_COUNT: usize = 7; pub struct ChatApp { controller: SessionController, pub mode: InputMode, pub status: String, pub error: Option, models: Vec, // All models fetched pub available_providers: Vec, // Unique providers from models pub selected_provider: String, // The currently selected provider pub selected_provider_index: usize, // Index into the available_providers list pub selected_model_item: Option, // Index into the flattened model selector list model_selector_items: Vec, // Flattened provider/model list for selector expanded_provider: Option, // Which provider group is currently expanded current_provider: String, // Provider backing the active session auto_scroll: AutoScroll, // Auto-scroll state for message rendering thinking_scroll: AutoScroll, // Auto-scroll state for thinking panel viewport_height: usize, // Track the height of the messages viewport thinking_viewport_height: usize, // Track the height of the thinking viewport content_width: usize, // Track the content width for line wrapping calculations session_tx: mpsc::UnboundedSender, streaming: HashSet, textarea: TextArea<'static>, // Advanced text input widget pending_llm_request: bool, // Flag to indicate LLM request needs to be processed pending_tool_execution: Option<(Uuid, Vec)>, // Pending tool execution (message_id, tool_calls) loading_animation_frame: usize, // Frame counter for loading animation is_loading: bool, // Whether we're currently loading a response current_thinking: Option, // Current thinking content from last assistant message // Holds the latest formatted Agentic ReAct actions (thought/action/observation) agent_actions: Option, pending_key: Option, // For multi-key sequences like gg, dd clipboard: String, // Vim-style clipboard for yank/paste command_buffer: String, // Buffer for command mode input command_suggestions: Vec, // Filtered command suggestions based on current input selected_suggestion: usize, // Index of selected suggestion visual_start: Option<(usize, usize)>, // Visual mode selection start (row, col) for Input panel visual_end: Option<(usize, usize)>, // Visual mode selection end (row, col) for scrollable panels focused_panel: FocusedPanel, // Currently focused panel for scrolling chat_cursor: (usize, usize), // Cursor position in Chat panel (row, col) thinking_cursor: (usize, usize), // Cursor position in Thinking panel (row, col) saved_sessions: Vec, // Cached list of saved sessions selected_session_index: usize, // Index of selected session in browser help_tab_index: usize, // Currently selected help tab (0-(HELP_TAB_COUNT-1)) theme: Theme, // Current theme available_themes: Vec, // Cached list of theme names selected_theme_index: usize, // Index of selected theme in browser pending_consent: Option, // Pending consent request system_status: String, // System/status messages (tool execution, status, etc) /// Simple execution budget: maximum number of tool calls allowed per session. _execution_budget: usize, /// Agent mode enabled agent_mode: bool, /// Agent running flag agent_running: bool, /// Operating mode (Chat or Code) operating_mode: owlen_core::mode::Mode, } #[derive(Clone, Debug)] pub struct ConsentDialogState { pub tool_name: String, pub data_types: Vec, pub endpoints: Vec, pub callback_id: Uuid, // ID to match callback with the request } impl ChatApp { pub async fn new( controller: SessionController, ) -> Result<(Self, mpsc::UnboundedReceiver)> { let (session_tx, session_rx) = mpsc::unbounded_channel(); let mut textarea = TextArea::default(); configure_textarea_defaults(&mut textarea); // Load theme and provider based on config before moving `controller`. let config_guard = controller.config_async().await; let theme_name = config_guard.ui.theme.clone(); let current_provider = config_guard.general.default_provider.clone(); drop(config_guard); let theme = owlen_core::theme::get_theme(&theme_name).unwrap_or_else(|| { eprintln!("Warning: Theme '{}' not found, using default", theme_name); Theme::default() }); let app = Self { controller, mode: InputMode::Normal, status: "Ready".to_string(), error: None, models: Vec::new(), available_providers: Vec::new(), selected_provider: "ollama".to_string(), // Default, will be updated in initialize_models selected_provider_index: 0, selected_model_item: None, model_selector_items: Vec::new(), expanded_provider: None, current_provider, auto_scroll: AutoScroll::default(), thinking_scroll: AutoScroll::default(), viewport_height: 10, // Default viewport height, will be updated during rendering thinking_viewport_height: 4, // Default thinking viewport height content_width: 80, // Default content width, will be updated during rendering session_tx, streaming: std::collections::HashSet::new(), textarea, pending_llm_request: false, pending_tool_execution: None, loading_animation_frame: 0, is_loading: false, current_thinking: None, agent_actions: None, pending_key: None, clipboard: String::new(), command_buffer: String::new(), command_suggestions: Vec::new(), selected_suggestion: 0, visual_start: None, visual_end: None, focused_panel: FocusedPanel::Input, chat_cursor: (0, 0), thinking_cursor: (0, 0), saved_sessions: Vec::new(), selected_session_index: 0, help_tab_index: 0, theme, available_themes: Vec::new(), selected_theme_index: 0, pending_consent: None, system_status: String::new(), _execution_budget: 50, agent_mode: false, agent_running: false, operating_mode: owlen_core::mode::Mode::default(), }; Ok((app, session_rx)) } /// Check if consent dialog is currently shown pub fn has_pending_consent(&self) -> bool { self.pending_consent.is_some() } /// Get the current consent dialog state pub fn consent_dialog(&self) -> Option<&ConsentDialogState> { self.pending_consent.as_ref() } pub fn status_message(&self) -> &str { &self.status } pub fn error_message(&self) -> Option<&String> { self.error.as_ref() } pub fn mode(&self) -> InputMode { self.mode } pub fn conversation(&self) -> &Conversation { self.controller.conversation() } pub fn selected_model(&self) -> &str { self.controller.selected_model() } // Synchronous access for UI rendering and other callers that expect an immediate Config. pub fn config(&self) -> tokio::sync::MutexGuard<'_, owlen_core::config::Config> { self.controller.config() } // Asynchronous version retained for places that already await the config. pub async fn config_async(&self) -> tokio::sync::MutexGuard<'_, owlen_core::config::Config> { self.controller.config_async().await } /// Get the current operating mode pub fn get_mode(&self) -> owlen_core::mode::Mode { self.operating_mode } /// Set the operating mode pub async fn set_mode(&mut self, mode: owlen_core::mode::Mode) { self.operating_mode = mode; self.status = format!("Switched to {} mode", mode); // Mode switching is handled by the SessionController's tool filtering } pub(crate) fn model_selector_items(&self) -> &[ModelSelectorItem] { &self.model_selector_items } pub fn selected_model_item(&self) -> Option { self.selected_model_item } pub(crate) fn model_info_by_index(&self, index: usize) -> Option<&ModelInfo> { self.models.get(index) } pub fn auto_scroll(&self) -> &AutoScroll { &self.auto_scroll } pub fn auto_scroll_mut(&mut self) -> &mut AutoScroll { &mut self.auto_scroll } pub fn scroll(&self) -> usize { self.auto_scroll.scroll } pub fn thinking_scroll(&self) -> &AutoScroll { &self.thinking_scroll } pub fn thinking_scroll_mut(&mut self) -> &mut AutoScroll { &mut self.thinking_scroll } pub fn thinking_scroll_position(&self) -> usize { self.thinking_scroll.scroll } pub fn message_count(&self) -> usize { self.controller.conversation().messages.len() } pub fn streaming_count(&self) -> usize { self.streaming.len() } pub fn formatter(&self) -> &owlen_core::formatting::MessageFormatter { self.controller.formatter() } pub fn input_buffer(&self) -> &owlen_core::input::InputBuffer { self.controller.input_buffer() } pub fn input_buffer_mut(&mut self) -> &mut owlen_core::input::InputBuffer { self.controller.input_buffer_mut() } pub fn textarea(&self) -> &TextArea<'static> { &self.textarea } pub fn textarea_mut(&mut self) -> &mut TextArea<'static> { &mut self.textarea } pub fn system_status(&self) -> &str { &self.system_status } pub fn set_system_status(&mut self, status: String) { self.system_status = status; } pub fn append_system_status(&mut self, status: &str) { if !self.system_status.is_empty() { self.system_status.push_str(" | "); } self.system_status.push_str(status); } pub fn clear_system_status(&mut self) { self.system_status.clear(); } pub fn command_buffer(&self) -> &str { &self.command_buffer } pub fn command_suggestions(&self) -> &[String] { &self.command_suggestions } pub fn selected_suggestion(&self) -> usize { self.selected_suggestion } /// Returns all available commands with their aliases fn get_all_commands() -> Vec<(&'static str, &'static str)> { vec![ ("quit", "Exit the application"), ("q", "Alias for quit"), ("clear", "Clear the conversation"), ("c", "Alias for clear"), ("w", "Alias for write"), ("save", "Alias for write"), ("load", "Load a saved conversation"), ("open", "Alias for load"), ("o", "Alias for load"), ("mode", "Switch operating mode (chat/code)"), ("code", "Switch to code mode"), ("chat", "Switch to chat mode"), ("tools", "List available tools in current mode"), ("sessions", "List saved sessions"), ("help", "Show help documentation"), ("h", "Alias for help"), ("model", "Select a model"), ("m", "Alias for model"), ("new", "Start a new conversation"), ("n", "Alias for new"), ("theme", "Switch theme"), ("themes", "List available themes"), ("reload", "Reload configuration and themes"), ("e", "Edit a file"), ("edit", "Alias for edit"), ("ls", "List directory contents"), ("privacy-enable", "Enable a privacy-sensitive tool"), ("privacy-disable", "Disable a privacy-sensitive tool"), ("privacy-clear", "Clear stored secure data"), ("agent", "Enable agent mode for autonomous task execution"), ("stop-agent", "Stop the running agent"), ] } /// Update command suggestions based on current input fn update_command_suggestions(&mut self) { let input = self.command_buffer.trim(); if input.is_empty() { // Show all commands when input is empty self.command_suggestions = Self::get_all_commands() .iter() .map(|(cmd, _)| cmd.to_string()) .collect(); } else { // Filter commands that start with the input self.command_suggestions = Self::get_all_commands() .iter() .filter_map(|(cmd, _)| { if cmd.starts_with(input) { Some(cmd.to_string()) } else { None } }) .collect(); } // Reset selection if out of bounds if self.selected_suggestion >= self.command_suggestions.len() { self.selected_suggestion = 0; } } /// Complete the current command with the selected suggestion fn complete_command(&mut self) { if let Some(suggestion) = self.command_suggestions.get(self.selected_suggestion) { self.command_buffer = suggestion.clone(); self.update_command_suggestions(); self.status = format!(":{}", self.command_buffer); } } pub fn focused_panel(&self) -> FocusedPanel { self.focused_panel } pub fn visual_selection(&self) -> Option<((usize, usize), (usize, usize))> { if let (Some(start), Some(end)) = (self.visual_start, self.visual_end) { Some((start, end)) } else { None } } pub fn chat_cursor(&self) -> (usize, usize) { self.chat_cursor } pub fn thinking_cursor(&self) -> (usize, usize) { self.thinking_cursor } pub fn saved_sessions(&self) -> &[SessionMeta] { &self.saved_sessions } pub fn selected_session_index(&self) -> usize { self.selected_session_index } pub fn help_tab_index(&self) -> usize { self.help_tab_index } pub fn available_themes(&self) -> &[String] { &self.available_themes } pub fn selected_theme_index(&self) -> usize { self.selected_theme_index } pub fn theme(&self) -> &Theme { &self.theme } pub fn set_theme(&mut self, theme: Theme) { self.theme = theme; } pub fn switch_theme(&mut self, theme_name: &str) -> Result<()> { if let Some(theme) = owlen_core::theme::get_theme(theme_name) { self.theme = theme; // Save theme to config self.controller.config_mut().ui.theme = theme_name.to_string(); if let Err(err) = config::save_config(&self.controller.config()) { self.error = Some(format!("Failed to save theme config: {}", err)); } else { self.status = format!("Switched to theme: {}", theme_name); } Ok(()) } else { self.error = Some(format!("Theme '{}' not found", theme_name)); Err(anyhow::anyhow!("Theme '{}' not found", theme_name)) } } pub fn cycle_focus_forward(&mut self) { self.focused_panel = match self.focused_panel { FocusedPanel::Chat => { if self.current_thinking.is_some() { FocusedPanel::Thinking } else { FocusedPanel::Input } } FocusedPanel::Thinking => FocusedPanel::Input, FocusedPanel::Input => FocusedPanel::Chat, }; } pub fn cycle_focus_backward(&mut self) { self.focused_panel = match self.focused_panel { FocusedPanel::Chat => FocusedPanel::Input, FocusedPanel::Thinking => FocusedPanel::Chat, FocusedPanel::Input => { if self.current_thinking.is_some() { FocusedPanel::Thinking } else { FocusedPanel::Chat } } }; } /// Sync textarea content to input buffer fn sync_textarea_to_buffer(&mut self) { let text = self.textarea.lines().join("\n"); self.input_buffer_mut().set_text(text); } /// Sync input buffer content to textarea fn sync_buffer_to_textarea(&mut self) { let text = self.input_buffer().text().to_string(); let lines: Vec = text.lines().map(|s| s.to_string()).collect(); self.textarea = TextArea::new(lines); configure_textarea_defaults(&mut self.textarea); } pub async fn initialize_models(&mut self) -> Result<()> { let config_model_name = self.controller.config().general.default_model.clone(); let config_model_provider = self.controller.config().general.default_provider.clone(); let (all_models, errors) = self.collect_models_from_all_providers().await; self.models = all_models; self.recompute_available_providers(); if self.available_providers.is_empty() { self.available_providers.push("ollama".to_string()); } if !config_model_provider.is_empty() { self.selected_provider = config_model_provider.clone(); } else { self.selected_provider = self.available_providers[0].clone(); } self.expanded_provider = Some(self.selected_provider.clone()); self.update_selected_provider_index(); self.sync_selected_model_index().await; // Ensure the default model is set in the controller and config (async) self.controller.ensure_default_model(&self.models).await; let current_model_name = self.controller.selected_model().to_string(); let current_model_provider = self.controller.config().general.default_provider.clone(); if config_model_name.as_deref() != Some(¤t_model_name) || config_model_provider != current_model_provider { if let Err(err) = config::save_config(&self.controller.config()) { self.error = Some(format!("Failed to save config: {err}")); } else { self.error = None; } } if !errors.is_empty() { self.error = Some(errors.join("; ")); } Ok(()) } pub async fn handle_event(&mut self, event: Event) -> Result { use crossterm::event::{KeyCode, KeyModifiers}; match event { Event::Tick => { // Future: update streaming timers } Event::Paste(text) => { // Handle paste events - insert text directly without triggering sends if matches!(self.mode, InputMode::Editing) { // In editing mode, insert the pasted text directly into textarea let lines: Vec<&str> = text.lines().collect(); for (i, line) in lines.iter().enumerate() { for ch in line.chars() { self.textarea.insert_char(ch); } // Insert newline between lines (but not after the last line) if i < lines.len() - 1 { self.textarea.insert_newline(); } } self.sync_textarea_to_buffer(); } // Ignore paste events in other modes } Event::Key(key) => { // Handle consent dialog first (highest priority) if let Some(consent_state) = &self.pending_consent { match key.code { KeyCode::Char('1') => { // Allow once let tool_name = consent_state.tool_name.clone(); let data_types = consent_state.data_types.clone(); let endpoints = consent_state.endpoints.clone(); self.controller.grant_consent_with_scope( &tool_name, data_types, endpoints, owlen_core::consent::ConsentScope::Once, ); self.pending_consent = None; self.status = format!("✓ Consent granted (once) for {}", tool_name); self.set_system_status(format!( "✓ Consent granted (once): {}", tool_name )); return Ok(AppState::Running); } KeyCode::Char('2') => { // Allow session let tool_name = consent_state.tool_name.clone(); let data_types = consent_state.data_types.clone(); let endpoints = consent_state.endpoints.clone(); self.controller.grant_consent_with_scope( &tool_name, data_types, endpoints, owlen_core::consent::ConsentScope::Session, ); self.pending_consent = None; self.status = format!("✓ Consent granted (session) for {}", tool_name); self.set_system_status(format!( "✓ Consent granted (session): {}", tool_name )); return Ok(AppState::Running); } KeyCode::Char('3') => { // Allow always (permanent) let tool_name = consent_state.tool_name.clone(); let data_types = consent_state.data_types.clone(); let endpoints = consent_state.endpoints.clone(); self.controller.grant_consent_with_scope( &tool_name, data_types, endpoints, owlen_core::consent::ConsentScope::Permanent, ); self.pending_consent = None; self.status = format!("✓ Consent granted (permanent) for {}", tool_name); self.set_system_status(format!( "✓ Consent granted (permanent): {}", tool_name )); return Ok(AppState::Running); } KeyCode::Char('4') | KeyCode::Esc => { // Deny consent - clear both consent and pending tool execution to prevent retry let tool_name = consent_state.tool_name.clone(); self.pending_consent = None; self.pending_tool_execution = None; // Clear to prevent infinite retry self.status = format!("✗ Consent denied for {}", tool_name); self.set_system_status(format!("✗ Consent denied: {}", tool_name)); self.error = Some(format!("Tool {} was blocked by user", tool_name)); return Ok(AppState::Running); } _ => { // Ignore other keys when consent dialog is shown return Ok(AppState::Running); } } } match self.mode { InputMode::Normal => { // Handle multi-key sequences first if let Some(pending) = self.pending_key { self.pending_key = None; match (pending, key.code) { ('g', KeyCode::Char('g')) => { self.jump_to_top(); } ('d', KeyCode::Char('d')) => { // Clear input buffer self.input_buffer_mut().clear(); self.textarea = TextArea::default(); configure_textarea_defaults(&mut self.textarea); self.status = "Input buffer cleared".to_string(); } _ => { // Invalid sequence, ignore } } return Ok(AppState::Running); } match (key.code, key.modifiers) { (KeyCode::Char('q'), KeyModifiers::NONE) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => { return Ok(AppState::Quit); } // Mode switches (KeyCode::Char('v'), KeyModifiers::NONE) => { self.mode = InputMode::Visual; match self.focused_panel { FocusedPanel::Input => { // Sync buffer to textarea before entering visual mode self.sync_buffer_to_textarea(); // Set a visible selection style self.textarea.set_selection_style( Style::default().bg(Color::LightBlue).fg(Color::Black), ); // Start visual selection at current cursor position self.textarea.start_selection(); self.visual_start = Some(self.textarea.cursor()); } FocusedPanel::Chat | FocusedPanel::Thinking => { // For scrollable panels, start selection at cursor position let cursor = if matches!(self.focused_panel, FocusedPanel::Chat) { self.chat_cursor } else { self.thinking_cursor }; self.visual_start = Some(cursor); self.visual_end = Some(cursor); } } self.status = "-- VISUAL -- (move with j/k, yank with y)".to_string(); } (KeyCode::Char(':'), KeyModifiers::NONE) => { self.mode = InputMode::Command; self.command_buffer.clear(); self.selected_suggestion = 0; self.update_command_suggestions(); self.status = ":".to_string(); } // Enter editing mode (KeyCode::Enter, KeyModifiers::NONE) | (KeyCode::Char('i'), KeyModifiers::NONE) => { self.mode = InputMode::Editing; self.sync_buffer_to_textarea(); } (KeyCode::Char('a'), KeyModifiers::NONE) => { // Append - move right and enter insert mode self.mode = InputMode::Editing; self.sync_buffer_to_textarea(); self.textarea.move_cursor(tui_textarea::CursorMove::Forward); } (KeyCode::Char('A'), KeyModifiers::SHIFT) => { // Append at end of line self.mode = InputMode::Editing; self.sync_buffer_to_textarea(); self.textarea.move_cursor(tui_textarea::CursorMove::End); } (KeyCode::Char('I'), KeyModifiers::SHIFT) => { // Insert at start of line self.mode = InputMode::Editing; self.sync_buffer_to_textarea(); self.textarea.move_cursor(tui_textarea::CursorMove::Head); } (KeyCode::Char('o'), KeyModifiers::NONE) => { // Insert newline below and enter edit mode self.mode = InputMode::Editing; self.sync_buffer_to_textarea(); self.textarea.move_cursor(tui_textarea::CursorMove::End); self.textarea.insert_newline(); } (KeyCode::Char('O'), KeyModifiers::NONE) => { // Insert newline above and enter edit mode self.mode = InputMode::Editing; self.sync_buffer_to_textarea(); self.textarea.move_cursor(tui_textarea::CursorMove::Head); self.textarea.insert_newline(); self.textarea.move_cursor(tui_textarea::CursorMove::Up); } // Basic scrolling and cursor movement (KeyCode::Up, KeyModifiers::NONE) | (KeyCode::Char('k'), KeyModifiers::NONE) => { match self.focused_panel { FocusedPanel::Chat => { if self.chat_cursor.0 > 0 { self.chat_cursor.0 -= 1; // Scroll if cursor moves above viewport if self.chat_cursor.0 < self.auto_scroll.scroll { self.on_scroll(-1); } } } FocusedPanel::Thinking => { if self.thinking_cursor.0 > 0 { self.thinking_cursor.0 -= 1; if self.thinking_cursor.0 < self.thinking_scroll.scroll { self.on_scroll(-1); } } } FocusedPanel::Input => { self.on_scroll(-1); } } } (KeyCode::Down, KeyModifiers::NONE) | (KeyCode::Char('j'), KeyModifiers::NONE) => { match self.focused_panel { FocusedPanel::Chat => { let max_lines = self.auto_scroll.content_len; if self.chat_cursor.0 + 1 < max_lines { self.chat_cursor.0 += 1; // Scroll if cursor moves below viewport let viewport_bottom = self.auto_scroll.scroll + self.viewport_height; if self.chat_cursor.0 >= viewport_bottom { self.on_scroll(1); } } } FocusedPanel::Thinking => { let max_lines = self.thinking_scroll.content_len; if self.thinking_cursor.0 + 1 < max_lines { self.thinking_cursor.0 += 1; let viewport_bottom = self.thinking_scroll.scroll + self.thinking_viewport_height; if self.thinking_cursor.0 >= viewport_bottom { self.on_scroll(1); } } } FocusedPanel::Input => { self.on_scroll(1); } } } // Horizontal cursor movement (KeyCode::Left, KeyModifiers::NONE) | (KeyCode::Char('h'), KeyModifiers::NONE) => { match self.focused_panel { FocusedPanel::Chat => { if self.chat_cursor.1 > 0 { self.chat_cursor.1 -= 1; } } FocusedPanel::Thinking => { if self.thinking_cursor.1 > 0 { self.thinking_cursor.1 -= 1; } } _ => {} } } (KeyCode::Right, KeyModifiers::NONE) | (KeyCode::Char('l'), KeyModifiers::NONE) => { match self.focused_panel { FocusedPanel::Chat => { if let Some(line) = self.get_line_at_row(self.chat_cursor.0) { let max_col = line.chars().count(); if self.chat_cursor.1 < max_col { self.chat_cursor.1 += 1; } } } FocusedPanel::Thinking => { if let Some(line) = self.get_line_at_row(self.thinking_cursor.0) { let max_col = line.chars().count(); if self.thinking_cursor.1 < max_col { self.thinking_cursor.1 += 1; } } } _ => {} } } // Word movement (KeyCode::Char('w'), KeyModifiers::NONE) => match self.focused_panel { FocusedPanel::Chat => { if let Some(new_col) = self.find_next_word_boundary( self.chat_cursor.0, self.chat_cursor.1, ) { self.chat_cursor.1 = new_col; } } FocusedPanel::Thinking => { if let Some(new_col) = self.find_next_word_boundary( self.thinking_cursor.0, self.thinking_cursor.1, ) { self.thinking_cursor.1 = new_col; } } _ => {} }, (KeyCode::Char('e'), KeyModifiers::NONE) => match self.focused_panel { FocusedPanel::Chat => { if let Some(new_col) = self.find_word_end(self.chat_cursor.0, self.chat_cursor.1) { self.chat_cursor.1 = new_col; } } FocusedPanel::Thinking => { if let Some(new_col) = self.find_word_end( self.thinking_cursor.0, self.thinking_cursor.1, ) { self.thinking_cursor.1 = new_col; } } _ => {} }, (KeyCode::Char('b'), KeyModifiers::NONE) => match self.focused_panel { FocusedPanel::Chat => { if let Some(new_col) = self.find_prev_word_boundary( self.chat_cursor.0, self.chat_cursor.1, ) { self.chat_cursor.1 = new_col; } } FocusedPanel::Thinking => { if let Some(new_col) = self.find_prev_word_boundary( self.thinking_cursor.0, self.thinking_cursor.1, ) { self.thinking_cursor.1 = new_col; } } _ => {} }, (KeyCode::Char('^'), KeyModifiers::SHIFT) => match self.focused_panel { FocusedPanel::Chat => { if let Some(line) = self.get_line_at_row(self.chat_cursor.0) { let first_non_blank = line .chars() .position(|c| !c.is_whitespace()) .unwrap_or(0); self.chat_cursor.1 = first_non_blank; } } FocusedPanel::Thinking => { if let Some(line) = self.get_line_at_row(self.thinking_cursor.0) { let first_non_blank = line .chars() .position(|c| !c.is_whitespace()) .unwrap_or(0); self.thinking_cursor.1 = first_non_blank; } } _ => {} }, // Line start/end navigation (KeyCode::Char('0'), KeyModifiers::NONE) | (KeyCode::Home, KeyModifiers::NONE) => match self.focused_panel { FocusedPanel::Chat => { self.chat_cursor.1 = 0; } FocusedPanel::Thinking => { self.thinking_cursor.1 = 0; } _ => {} }, (KeyCode::Char('$'), KeyModifiers::NONE) | (KeyCode::End, KeyModifiers::NONE) => match self.focused_panel { FocusedPanel::Chat => { if let Some(line) = self.get_line_at_row(self.chat_cursor.0) { self.chat_cursor.1 = line.chars().count(); } } FocusedPanel::Thinking => { if let Some(line) = self.get_line_at_row(self.thinking_cursor.0) { self.thinking_cursor.1 = line.chars().count(); } } _ => {} }, // Half-page scrolling (KeyCode::Char('d'), KeyModifiers::CONTROL) => { self.scroll_half_page_down(); } (KeyCode::Char('u'), KeyModifiers::CONTROL) => { self.scroll_half_page_up(); } // Full-page scrolling (KeyCode::Char('f'), KeyModifiers::CONTROL) | (KeyCode::PageDown, KeyModifiers::NONE) => { self.scroll_full_page_down(); } (KeyCode::Char('b'), KeyModifiers::CONTROL) | (KeyCode::PageUp, KeyModifiers::NONE) => { self.scroll_full_page_up(); } // Jump to top/bottom (KeyCode::Char('G'), KeyModifiers::SHIFT) => { self.jump_to_bottom(); } // Multi-key sequences (KeyCode::Char('g'), KeyModifiers::NONE) => { self.pending_key = Some('g'); self.status = "g".to_string(); } (KeyCode::Char('d'), KeyModifiers::NONE) => { self.pending_key = Some('d'); self.status = "d".to_string(); } // Yank/paste (works from any panel) (KeyCode::Char('p'), KeyModifiers::NONE) => { if !self.clipboard.is_empty() { // Always paste into Input panel let current_lines = self.textarea.lines().to_vec(); let clipboard_lines: Vec = self.clipboard.lines().map(|s| s.to_string()).collect(); // Append clipboard content to current input let mut new_lines = current_lines; if new_lines.is_empty() || new_lines == vec![String::new()] { new_lines = clipboard_lines; } else { // Add newline and append new_lines.push(String::new()); new_lines.extend(clipboard_lines); } self.textarea = TextArea::new(new_lines); configure_textarea_defaults(&mut self.textarea); self.sync_textarea_to_buffer(); self.status = "Pasted into input".to_string(); } } // Panel switching (KeyCode::Tab, KeyModifiers::NONE) => { self.cycle_focus_forward(); let panel_name = match self.focused_panel { FocusedPanel::Chat => "Chat", FocusedPanel::Thinking => "Thinking", FocusedPanel::Input => "Input", }; self.status = format!("Focus: {}", panel_name); } (KeyCode::BackTab, KeyModifiers::SHIFT) => { self.cycle_focus_backward(); let panel_name = match self.focused_panel { FocusedPanel::Chat => "Chat", FocusedPanel::Thinking => "Thinking", FocusedPanel::Input => "Input", }; self.status = format!("Focus: {}", panel_name); } (KeyCode::Esc, KeyModifiers::NONE) => { self.pending_key = None; self.mode = InputMode::Normal; } _ => { self.pending_key = None; } } } InputMode::Editing => match (key.code, key.modifiers) { (KeyCode::Esc, KeyModifiers::NONE) => { // Sync textarea content to input buffer before leaving edit mode self.sync_textarea_to_buffer(); self.mode = InputMode::Normal; self.reset_status(); } (KeyCode::Char('j' | 'J'), m) if m.contains(KeyModifiers::CONTROL) => { self.textarea.insert_newline(); } (KeyCode::Enter, KeyModifiers::NONE) => { // Send message and return to normal mode self.sync_textarea_to_buffer(); self.send_user_message_and_request_response(); // Clear the textarea by setting it to empty self.textarea = TextArea::default(); configure_textarea_defaults(&mut self.textarea); self.mode = InputMode::Normal; } (KeyCode::Enter, _) => { // Any Enter with modifiers keeps editing and inserts a newline via tui-textarea self.textarea.input(Input::from(key)); } // History navigation (KeyCode::Up, m) if m.contains(KeyModifiers::CONTROL) => { self.input_buffer_mut().history_previous(); self.sync_buffer_to_textarea(); } (KeyCode::Down, m) if m.contains(KeyModifiers::CONTROL) => { self.input_buffer_mut().history_next(); self.sync_buffer_to_textarea(); } // Vim-style navigation with Ctrl (KeyCode::Char('a'), m) if m.contains(KeyModifiers::CONTROL) => { self.textarea.move_cursor(tui_textarea::CursorMove::Head); } (KeyCode::Char('e'), m) if m.contains(KeyModifiers::CONTROL) => { self.textarea.move_cursor(tui_textarea::CursorMove::End); } (KeyCode::Char('w'), m) if m.contains(KeyModifiers::CONTROL) => { self.textarea .move_cursor(tui_textarea::CursorMove::WordForward); } (KeyCode::Char('b'), m) if m.contains(KeyModifiers::CONTROL) => { self.textarea .move_cursor(tui_textarea::CursorMove::WordBack); } (KeyCode::Char('r'), m) if m.contains(KeyModifiers::CONTROL) => { // Redo - history next self.input_buffer_mut().history_next(); self.sync_buffer_to_textarea(); } _ => { // Let tui-textarea handle all other input self.textarea.input(Input::from(key)); } }, InputMode::Visual => match (key.code, key.modifiers) { (KeyCode::Esc, _) | (KeyCode::Char('v'), KeyModifiers::NONE) => { // Cancel selection and return to normal mode if matches!(self.focused_panel, FocusedPanel::Input) { self.textarea.cancel_selection(); } self.mode = InputMode::Normal; self.visual_start = None; self.visual_end = None; self.reset_status(); } (KeyCode::Char('y'), KeyModifiers::NONE) => { match self.focused_panel { FocusedPanel::Input => { // Yank selected text using tui-textarea's copy self.textarea.copy(); // Get the yanked text from textarea's internal clipboard let yanked = self.textarea.yank_text(); if !yanked.is_empty() { self.clipboard = yanked; self.status = format!("Yanked {} chars", self.clipboard.len()); } else { // Fall back to yanking current line if no selection let (row, _) = self.textarea.cursor(); if let Some(line) = self.textarea.lines().get(row) { self.clipboard = line.clone(); self.status = format!( "Yanked line ({} chars)", self.clipboard.len() ); } } self.textarea.cancel_selection(); } FocusedPanel::Chat | FocusedPanel::Thinking => { // Yank selected lines from scrollable panels if let Some(yanked) = self.yank_from_panel() { self.clipboard = yanked; self.status = format!("Yanked {} chars", self.clipboard.len()); } else { self.status = "Nothing to yank".to_string(); } } } self.mode = InputMode::Normal; self.visual_start = None; self.visual_end = None; } (KeyCode::Char('d'), KeyModifiers::NONE) | (KeyCode::Delete, _) => { match self.focused_panel { FocusedPanel::Input => { // Cut (delete) selected text using tui-textarea's cut if self.textarea.cut() { // Get the cut text let cut_text = self.textarea.yank_text(); self.clipboard = cut_text; self.sync_textarea_to_buffer(); self.status = format!("Cut {} chars", self.clipboard.len()); } else { self.status = "Nothing to cut".to_string(); } self.textarea.cancel_selection(); } FocusedPanel::Chat | FocusedPanel::Thinking => { // Can't delete from read-only panels, just yank if let Some(yanked) = self.yank_from_panel() { self.clipboard = yanked; self.status = format!( "Yanked {} chars (read-only panel)", self.clipboard.len() ); } else { self.status = "Nothing to yank".to_string(); } } } self.mode = InputMode::Normal; self.visual_start = None; self.visual_end = None; } // Movement keys to extend selection (KeyCode::Left, _) | (KeyCode::Char('h'), KeyModifiers::NONE) => { match self.focused_panel { FocusedPanel::Input => { self.textarea.move_cursor(tui_textarea::CursorMove::Back); } FocusedPanel::Chat | FocusedPanel::Thinking => { // Move selection left (decrease column) if let Some((row, col)) = self.visual_end { if col > 0 { self.visual_end = Some((row, col - 1)); } } } } } (KeyCode::Right, _) | (KeyCode::Char('l'), KeyModifiers::NONE) => { match self.focused_panel { FocusedPanel::Input => { self.textarea.move_cursor(tui_textarea::CursorMove::Forward); } FocusedPanel::Chat | FocusedPanel::Thinking => { // Move selection right (increase column) if let Some((row, col)) = self.visual_end { self.visual_end = Some((row, col + 1)); } } } } (KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::NONE) => { match self.focused_panel { FocusedPanel::Input => { self.textarea.move_cursor(tui_textarea::CursorMove::Up); } FocusedPanel::Chat | FocusedPanel::Thinking => { // Move selection up (decrease end row) if let Some((row, col)) = self.visual_end { if row > 0 { self.visual_end = Some((row - 1, col)); // Scroll if needed to keep selection visible self.on_scroll(-1); } } } } } (KeyCode::Down, _) | (KeyCode::Char('j'), KeyModifiers::NONE) => { match self.focused_panel { FocusedPanel::Input => { self.textarea.move_cursor(tui_textarea::CursorMove::Down); } FocusedPanel::Chat | FocusedPanel::Thinking => { // Move selection down (increase end row) if let Some((row, col)) = self.visual_end { // Get max lines for the current panel let max_lines = if matches!(self.focused_panel, FocusedPanel::Chat) { self.auto_scroll.content_len } else { self.thinking_scroll.content_len }; if row + 1 < max_lines { self.visual_end = Some((row + 1, col)); // Scroll if needed to keep selection visible self.on_scroll(1); } } } } } (KeyCode::Char('w'), KeyModifiers::NONE) => { match self.focused_panel { FocusedPanel::Input => { self.textarea .move_cursor(tui_textarea::CursorMove::WordForward); } FocusedPanel::Chat | FocusedPanel::Thinking => { // Move selection forward by word if let Some((row, col)) = self.visual_end { if let Some(new_col) = self.find_next_word_boundary(row, col) { self.visual_end = Some((row, new_col)); } } } } } (KeyCode::Char('b'), KeyModifiers::NONE) => { match self.focused_panel { FocusedPanel::Input => { self.textarea .move_cursor(tui_textarea::CursorMove::WordBack); } FocusedPanel::Chat | FocusedPanel::Thinking => { // Move selection backward by word if let Some((row, col)) = self.visual_end { if let Some(new_col) = self.find_prev_word_boundary(row, col) { self.visual_end = Some((row, new_col)); } } } } } (KeyCode::Char('0'), KeyModifiers::NONE) | (KeyCode::Home, _) => { match self.focused_panel { FocusedPanel::Input => { self.textarea.move_cursor(tui_textarea::CursorMove::Head); } FocusedPanel::Chat | FocusedPanel::Thinking => { // Move selection to start of line if let Some((row, _)) = self.visual_end { self.visual_end = Some((row, 0)); } } } } (KeyCode::Char('$'), KeyModifiers::NONE) | (KeyCode::End, _) => { match self.focused_panel { FocusedPanel::Input => { self.textarea.move_cursor(tui_textarea::CursorMove::End); } FocusedPanel::Chat | FocusedPanel::Thinking => { // Move selection to end of line if let Some((row, _)) = self.visual_end { if let Some(line) = self.get_line_at_row(row) { let line_len = line.chars().count(); self.visual_end = Some((row, line_len)); } } } } } _ => { // Ignore all other input in visual mode (no typing allowed) } }, InputMode::Command => match (key.code, key.modifiers) { (KeyCode::Esc, _) => { self.mode = InputMode::Normal; self.command_buffer.clear(); self.command_suggestions.clear(); self.reset_status(); } (KeyCode::Tab, _) => { // Tab completion self.complete_command(); } (KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::CONTROL) => { // Navigate up in suggestions if !self.command_suggestions.is_empty() { self.selected_suggestion = self.selected_suggestion.saturating_sub(1); } } (KeyCode::Down, _) | (KeyCode::Char('j'), KeyModifiers::CONTROL) => { // Navigate down in suggestions if !self.command_suggestions.is_empty() { self.selected_suggestion = (self.selected_suggestion + 1) .min(self.command_suggestions.len().saturating_sub(1)); } } (KeyCode::Enter, _) => { // Execute command let cmd = self.command_buffer.trim(); let parts: Vec<&str> = cmd.split_whitespace().collect(); let command = parts.first().copied().unwrap_or(""); let args = &parts[1..]; match command { "q" | "quit" => { return Ok(AppState::Quit); } "c" | "clear" => { self.controller.clear(); self.status = "Conversation cleared".to_string(); } "w" | "write" | "save" => { // Save current conversation with AI-generated description let name = if !args.is_empty() { Some(args.join(" ")) } else { None }; // Generate description if enabled in config let description = if self.controller.config().storage.generate_descriptions { self.status = "Generating description...".to_string(); (self .controller .generate_conversation_description() .await) .ok() } else { None }; // Save the conversation with description match self .controller .save_active_session(name.clone(), description) .await { Ok(id) => { self.status = if let Some(name) = name { format!("Session saved: {name} ({id})") } else { format!("Session saved with id {id}") }; self.error = None; } Err(e) => { self.error = Some(format!("Failed to save session: {}", e)); } } } "load" | "open" | "o" => { // Load saved sessions and enter browser mode match self.controller.list_saved_sessions().await { Ok(sessions) => { self.saved_sessions = sessions; self.selected_session_index = 0; self.mode = InputMode::SessionBrowser; self.command_buffer.clear(); self.command_suggestions.clear(); return Ok(AppState::Running); } Err(e) => { self.error = Some(format!("Failed to list sessions: {}", e)); } } } "sessions" => { // List saved sessions match self.controller.list_saved_sessions().await { Ok(sessions) => { self.saved_sessions = sessions; self.selected_session_index = 0; self.mode = InputMode::SessionBrowser; self.command_buffer.clear(); self.command_suggestions.clear(); return Ok(AppState::Running); } Err(e) => { self.error = Some(format!("Failed to list sessions: {}", e)); } } } "mode" => { // Switch mode with argument: :mode chat or :mode code if args.is_empty() { self.status = format!( "Current mode: {}. Usage: :mode ", self.operating_mode ); } else { let mode_str = args[0]; match mode_str.parse::() { Ok(new_mode) => { self.set_mode(new_mode).await; } Err(err) => { self.error = Some(err); } } } } "code" => { // Shortcut to switch to code mode self.set_mode(owlen_core::mode::Mode::Code).await; } "chat" => { // Shortcut to switch to chat mode self.set_mode(owlen_core::mode::Mode::Chat).await; } "tools" => { // List available tools in current mode let available_tools: Vec = { let config = self.config_async().await; vec![ "web_search".to_string(), "code_exec".to_string(), "file_write".to_string(), ] .into_iter() .filter(|tool| { config.modes.is_tool_allowed(self.operating_mode, tool) }) .collect() }; // config dropped here if available_tools.is_empty() { self.status = format!( "No tools available in {} mode", self.operating_mode ); } else { self.status = format!( "Available tools in {} mode: {}", self.operating_mode, available_tools.join(", ") ); } } "h" | "help" => { self.mode = InputMode::Help; self.command_buffer.clear(); self.command_suggestions.clear(); return Ok(AppState::Running); } "m" | "model" => { self.refresh_models().await?; self.mode = InputMode::ProviderSelection; self.command_buffer.clear(); self.command_suggestions.clear(); return Ok(AppState::Running); } // "run-agent" command removed to break circular dependency on owlen-cli. "agent" => { if self.agent_running { self.status = "Agent is already running".to_string(); } else { self.agent_mode = true; self.status = "Agent mode enabled. Next message will be processed by agent.".to_string(); } } "stop-agent" => { if self.agent_running { self.agent_running = false; self.agent_mode = false; self.status = "Agent execution stopped".to_string(); self.agent_actions = None; } else { self.status = "No agent is currently running".to_string(); } } "n" | "new" => { self.controller.start_new_conversation(None, None); self.status = "Started new conversation".to_string(); } "e" | "edit" => { if let Some(path) = args.first() { match self.controller.read_file(path).await { Ok(content) => { let message = format!( "The content of file `{}` is:\n```\n{}\n```", path, content ); self.controller .conversation_mut() .push_user_message(message); self.pending_llm_request = true; } Err(e) => { self.error = Some(format!("Failed to read file: {}", e)); } } } else { self.error = Some("Usage: :e ".to_string()); } } "ls" => { let path = args.first().copied().unwrap_or("."); match self.controller.list_dir(path).await { Ok(entries) => { let message = format!( "Directory listing for `{}`:\n```\n{}\n```", path, entries.join("\n") ); self.controller .conversation_mut() .push_user_message(message); } Err(e) => { self.error = Some(format!("Failed to list directory: {}", e)); } } } "theme" => { if args.is_empty() { self.error = Some("Usage: :theme ".to_string()); } else { let theme_name = args.join(" "); match self.switch_theme(&theme_name) { Ok(_) => { // Success message already set by switch_theme } Err(_) => { // Error message already set by switch_theme } } } } "themes" => { // Load all themes and enter browser mode let themes = owlen_core::theme::load_all_themes(); let mut theme_list: Vec = themes.keys().cloned().collect(); theme_list.sort(); self.available_themes = theme_list; // Set selected index to current theme let current_theme = &self.theme.name; self.selected_theme_index = self .available_themes .iter() .position(|name| name == current_theme) .unwrap_or(0); self.mode = InputMode::ThemeBrowser; self.command_buffer.clear(); self.command_suggestions.clear(); return Ok(AppState::Running); } "reload" => { // Reload config match owlen_core::config::Config::load(None) { Ok(new_config) => { // Update controller config *self.controller.config_mut() = new_config.clone(); // Reload theme based on updated config let theme_name = &new_config.ui.theme; if let Some(new_theme) = owlen_core::theme::get_theme(theme_name) { self.theme = new_theme; self.status = format!( "Configuration and theme reloaded (theme: {})", theme_name ); } else { self.status = "Configuration reloaded, but theme not found. Using current theme.".to_string(); } self.error = None; } Err(e) => { self.error = Some(format!("Failed to reload config: {}", e)); } } } "privacy-enable" => { if let Some(tool) = args.first() { match self.controller.set_tool_enabled(tool, true).await { Ok(_) => { if let Err(err) = config::save_config(&self.controller.config()) { self.error = Some(format!( "Enabled {tool}, but failed to save config: {err}" )); } else { self.status = format!("Enabled tool: {tool}"); self.error = None; } } Err(e) => { self.error = Some(format!("Failed to enable tool: {}", e)); } } } else { self.error = Some("Usage: :privacy-enable ".to_string()); } } "privacy-disable" => { if let Some(tool) = args.first() { match self.controller.set_tool_enabled(tool, false).await { Ok(_) => { if let Err(err) = config::save_config(&self.controller.config()) { self.error = Some(format!( "Disabled {tool}, but failed to save config: {err}" )); } else { self.status = format!("Disabled tool: {tool}"); self.error = None; } } Err(e) => { self.error = Some(format!("Failed to disable tool: {}", e)); } } } else { self.error = Some("Usage: :privacy-disable ".to_string()); } } "privacy-clear" => { match self.controller.clear_secure_data().await { Ok(_) => { self.status = "Cleared secure stored data".to_string(); self.error = None; } Err(e) => { self.error = Some(format!("Failed to clear secure data: {}", e)); } } } _ => { self.error = Some(format!("Unknown command: {}", cmd)); } } self.command_buffer.clear(); self.command_suggestions.clear(); self.mode = InputMode::Normal; } (KeyCode::Char(c), KeyModifiers::NONE) | (KeyCode::Char(c), KeyModifiers::SHIFT) => { self.command_buffer.push(c); self.update_command_suggestions(); self.status = format!(":{}", self.command_buffer); } (KeyCode::Backspace, _) => { self.command_buffer.pop(); self.update_command_suggestions(); self.status = format!(":{}", self.command_buffer); } _ => {} }, InputMode::ProviderSelection => match key.code { KeyCode::Esc => { self.mode = InputMode::Normal; } KeyCode::Enter => { if let Some(provider) = self.available_providers.get(self.selected_provider_index) { self.selected_provider = provider.clone(); // Update model selection based on new provider (await async) self.sync_selected_model_index().await; // Update model selection based on new provider self.mode = InputMode::ModelSelection; } } KeyCode::Up => { if self.selected_provider_index > 0 { self.selected_provider_index -= 1; } } KeyCode::Down => { if self.selected_provider_index + 1 < self.available_providers.len() { self.selected_provider_index += 1; } } _ => {} }, InputMode::ModelSelection => match key.code { KeyCode::Esc => { self.mode = InputMode::Normal; } KeyCode::Enter => { if let Some(item) = self.current_model_selector_item() { match item.kind() { ModelSelectorItemKind::Header { provider, expanded } => { if *expanded { let provider_name = provider.clone(); self.collapse_provider(&provider_name); self.status = format!("Collapsed provider: {}", provider_name); } else { let provider_name = provider.clone(); self.expand_provider(&provider_name, true); self.status = format!("Expanded provider: {}", provider_name); } self.error = None; } ModelSelectorItemKind::Model { .. } => { if let Some(model) = self.selected_model_info().cloned() { let model_id = model.id.clone(); let model_label = if model.name.is_empty() { model.id.clone() } else { model.name.clone() }; if let Err(err) = self.switch_to_provider(&model.provider).await { self.error = Some(format!( "Failed to switch provider: {}", err )); self.status = "Provider switch failed".to_string(); return Ok(AppState::Running); } self.selected_provider = model.provider.clone(); self.update_selected_provider_index(); // Set the selected model asynchronously self.controller.set_model(model_id.clone()).await; self.status = format!( "Using model: {} (provider: {})", model_label, self.selected_provider ); // Save the selected provider and model to config self.controller.config_mut().general.default_model = Some(model_id.clone()); self.controller.config_mut().general.default_provider = self.selected_provider.clone(); match config::save_config(&self.controller.config()) { Ok(_) => self.error = None, Err(err) => { self.error = Some(format!( "Failed to save config: {}", err )); } } self.mode = InputMode::Normal; } else { self.error = Some( "No model available for the selected provider" .to_string(), ); } } ModelSelectorItemKind::Empty { provider } => { let provider_name = provider.clone(); self.collapse_provider(&provider_name); self.status = format!("Collapsed provider: {}", provider_name); self.error = None; } } } } KeyCode::Up => { self.move_model_selection(-1); } KeyCode::Down => { self.move_model_selection(1); } KeyCode::Left => { if let Some(item) = self.current_model_selector_item() { match item.kind() { ModelSelectorItemKind::Header { provider, expanded } => { if *expanded { let provider_name = provider.clone(); self.collapse_provider(&provider_name); self.status = format!("Collapsed provider: {}", provider_name); self.error = None; } } ModelSelectorItemKind::Model { provider, .. } => { if let Some(idx) = self.index_of_header(provider) { self.set_selected_model_item(idx); } } ModelSelectorItemKind::Empty { provider } => { let provider_name = provider.clone(); self.collapse_provider(&provider_name); self.status = format!("Collapsed provider: {}", provider_name); self.error = None; } } } } KeyCode::Right => { if let Some(item) = self.current_model_selector_item() { match item.kind() { ModelSelectorItemKind::Header { provider, expanded } => { if !expanded { let provider_name = provider.clone(); self.expand_provider(&provider_name, true); self.status = format!("Expanded provider: {}", provider_name); self.error = None; } } ModelSelectorItemKind::Empty { provider } => { let provider_name = provider.clone(); self.expand_provider(&provider_name, false); self.status = format!("Expanded provider: {}", provider_name); self.error = None; } _ => {} } } } KeyCode::Char(' ') => { if let Some(item) = self.current_model_selector_item() { if let ModelSelectorItemKind::Header { provider, expanded } = item.kind() { if *expanded { let provider_name = provider.clone(); self.collapse_provider(&provider_name); self.status = format!("Collapsed provider: {}", provider_name); } else { let provider_name = provider.clone(); self.expand_provider(&provider_name, true); self.status = format!("Expanded provider: {}", provider_name); } self.error = None; } } } _ => {} }, InputMode::Help => match key.code { KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => { self.mode = InputMode::Normal; self.help_tab_index = 0; // Reset to first tab } KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { // Next tab if self.help_tab_index + 1 < HELP_TAB_COUNT { self.help_tab_index += 1; } } KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { // Previous tab if self.help_tab_index > 0 { self.help_tab_index -= 1; } } KeyCode::Char(ch) if ch.is_ascii_digit() => { if let Some(idx) = ch.to_digit(10) { if idx >= 1 && (idx as usize) <= HELP_TAB_COUNT { self.help_tab_index = (idx - 1) as usize; } } } _ => {} }, InputMode::SessionBrowser => match key.code { KeyCode::Esc => { self.mode = InputMode::Normal; } KeyCode::Enter => { // Load selected session if let Some(session) = self.saved_sessions.get(self.selected_session_index) { match self.controller.load_saved_session(session.id).await { Ok(_) => { self.status = format!( "Loaded session: {}", session.name.as_deref().unwrap_or("Unnamed"), ); self.error = None; self.update_thinking_from_last_message(); } Err(e) => { self.error = Some(format!("Failed to load session: {}", e)); } } } self.mode = InputMode::Normal; } KeyCode::Up | KeyCode::Char('k') => { if self.selected_session_index > 0 { self.selected_session_index -= 1; } } KeyCode::Down | KeyCode::Char('j') => { if self.selected_session_index + 1 < self.saved_sessions.len() { self.selected_session_index += 1; } } KeyCode::Char('d') => { // Delete selected session if let Some(session) = self.saved_sessions.get(self.selected_session_index) { match self.controller.delete_session(session.id).await { Ok(_) => { self.saved_sessions.remove(self.selected_session_index); if self.selected_session_index >= self.saved_sessions.len() && !self.saved_sessions.is_empty() { self.selected_session_index = self.saved_sessions.len() - 1; } self.status = "Session deleted".to_string(); } Err(e) => { self.error = Some(format!("Failed to delete session: {}", e)); } } } } _ => {} }, InputMode::ThemeBrowser => match key.code { KeyCode::Esc | KeyCode::Char('q') => { self.mode = InputMode::Normal; } KeyCode::Enter => { // Apply selected theme if let Some(theme_name) = self .available_themes .get(self.selected_theme_index) .cloned() { match self.switch_theme(&theme_name) { Ok(_) => { // Success message already set by switch_theme } Err(_) => { // Error message already set by switch_theme } } } self.mode = InputMode::Normal; } KeyCode::Up | KeyCode::Char('k') => { if self.selected_theme_index > 0 { self.selected_theme_index -= 1; } } KeyCode::Down | KeyCode::Char('j') => { if self.selected_theme_index + 1 < self.available_themes.len() { self.selected_theme_index += 1; } } KeyCode::Home | KeyCode::Char('g') => { self.selected_theme_index = 0; } KeyCode::End | KeyCode::Char('G') => { if !self.available_themes.is_empty() { self.selected_theme_index = self.available_themes.len() - 1; } } _ => {} }, } } _ => {} } Ok(AppState::Running) } /// Call this when processing scroll up/down keys pub fn on_scroll(&mut self, delta: isize) { match self.focused_panel { FocusedPanel::Chat => { self.auto_scroll.on_user_scroll(delta, self.viewport_height); } FocusedPanel::Thinking => { // Ensure we have a valid viewport height let viewport_height = self.thinking_viewport_height.max(1); self.thinking_scroll.on_user_scroll(delta, viewport_height); } FocusedPanel::Input => { // Input panel doesn't scroll } } } /// Scroll down half page pub fn scroll_half_page_down(&mut self) { match self.focused_panel { FocusedPanel::Chat => { self.auto_scroll.scroll_half_page_down(self.viewport_height); } FocusedPanel::Thinking => { let viewport_height = self.thinking_viewport_height.max(1); self.thinking_scroll.scroll_half_page_down(viewport_height); } FocusedPanel::Input => {} } } /// Scroll up half page pub fn scroll_half_page_up(&mut self) { match self.focused_panel { FocusedPanel::Chat => { self.auto_scroll.scroll_half_page_up(self.viewport_height); } FocusedPanel::Thinking => { let viewport_height = self.thinking_viewport_height.max(1); self.thinking_scroll.scroll_half_page_up(viewport_height); } FocusedPanel::Input => {} } } /// Scroll down full page pub fn scroll_full_page_down(&mut self) { match self.focused_panel { FocusedPanel::Chat => { self.auto_scroll.scroll_full_page_down(self.viewport_height); } FocusedPanel::Thinking => { let viewport_height = self.thinking_viewport_height.max(1); self.thinking_scroll.scroll_full_page_down(viewport_height); } FocusedPanel::Input => {} } } /// Scroll up full page pub fn scroll_full_page_up(&mut self) { match self.focused_panel { FocusedPanel::Chat => { self.auto_scroll.scroll_full_page_up(self.viewport_height); } FocusedPanel::Thinking => { let viewport_height = self.thinking_viewport_height.max(1); self.thinking_scroll.scroll_full_page_up(viewport_height); } FocusedPanel::Input => {} } } /// Jump to top of focused panel pub fn jump_to_top(&mut self) { match self.focused_panel { FocusedPanel::Chat => { self.auto_scroll.jump_to_top(); } FocusedPanel::Thinking => { self.thinking_scroll.jump_to_top(); } FocusedPanel::Input => {} } } /// Jump to bottom of focused panel pub fn jump_to_bottom(&mut self) { match self.focused_panel { FocusedPanel::Chat => { self.auto_scroll.jump_to_bottom(self.viewport_height); } FocusedPanel::Thinking => { let viewport_height = self.thinking_viewport_height.max(1); self.thinking_scroll.jump_to_bottom(viewport_height); } FocusedPanel::Input => {} } } pub fn handle_session_event(&mut self, event: SessionEvent) -> Result<()> { match event { SessionEvent::StreamChunk { message_id, response, } => { self.controller.apply_stream_chunk(message_id, &response)?; // Update thinking content in real-time during streaming self.update_thinking_from_last_message(); // Auto-scroll will handle this in the render loop if response.is_final { self.streaming.remove(&message_id); self.stop_loading_animation(); // Check if the completed stream has tool calls that need execution if let Some(tool_calls) = self.controller.check_streaming_tool_calls(message_id) { // Trigger tool execution via event let sender = self.session_tx.clone(); let _ = sender.send(SessionEvent::ToolExecutionNeeded { message_id, tool_calls, }); } else { self.status = "Ready".to_string(); } } } SessionEvent::StreamError { message } => { self.stop_loading_animation(); self.error = Some(message); } SessionEvent::ToolExecutionNeeded { message_id, tool_calls, } => { // Store tool execution for async processing on next event loop iteration self.pending_tool_execution = Some((message_id, tool_calls)); } SessionEvent::ConsentNeeded { tool_name, data_types, endpoints, callback_id, } => { // Show consent dialog self.pending_consent = Some(ConsentDialogState { tool_name, data_types, endpoints, callback_id, }); self.status = "Consent required - Press Y to allow, N to deny".to_string(); } SessionEvent::AgentUpdate { content } => { // Update agent actions panel with latest ReAct iteration self.set_agent_actions(content); } SessionEvent::AgentCompleted { answer } => { // Agent finished, add final answer to conversation self.controller .conversation_mut() .push_assistant_message(answer); self.agent_running = false; self.agent_mode = false; self.agent_actions = None; self.status = "Agent completed successfully".to_string(); self.stop_loading_animation(); } SessionEvent::AgentFailed { error } => { // Agent failed, show error self.error = Some(format!("Agent failed: {}", error)); self.agent_running = false; self.agent_actions = None; self.stop_loading_animation(); } } Ok(()) } fn reset_status(&mut self) { self.status = "Ready".to_string(); self.error = None; } async fn collect_models_from_all_providers(&self) -> (Vec, Vec) { let provider_entries = { let config = self.controller.config(); let entries: Vec<(String, ProviderConfig)> = config .providers .iter() .map(|(name, cfg)| (name.clone(), cfg.clone())) .collect(); entries }; let mut models = Vec::new(); let mut errors = Vec::new(); for (name, provider_cfg) in provider_entries { let provider_type = provider_cfg.provider_type.to_ascii_lowercase(); if provider_type != "ollama" && provider_type != "ollama-cloud" { continue; } // All providers communicate via MCP LLM server (Phase 10). // For cloud providers, the URL is passed via the provider config. let client_result = if provider_type == "ollama-cloud" { // Cloud Ollama - create MCP client with custom URL via env var use owlen_core::config::McpServerConfig; use std::collections::HashMap; let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../..") .canonicalize() .ok(); let binary_path = workspace_root.and_then(|root| { let candidates = [ "target/debug/owlen-mcp-llm-server", "target/release/owlen-mcp-llm-server", ]; candidates .iter() .map(|rel| root.join(rel)) .find(|p| p.exists()) }); if let Some(path) = binary_path { let mut env_vars = HashMap::new(); if let Some(url) = &provider_cfg.base_url { env_vars.insert("OLLAMA_URL".to_string(), url.clone()); } let config = McpServerConfig { name: name.clone(), command: path.to_string_lossy().into_owned(), args: Vec::new(), transport: "stdio".to_string(), env: env_vars, }; RemoteMcpClient::new_with_config(&config) } else { Err(owlen_core::Error::NotImplemented( "MCP server binary not found".into(), )) } } else { // Local Ollama - use default MCP client RemoteMcpClient::new() }; match client_result { Ok(client) => match client.list_models().await { Ok(mut provider_models) => { for model in &mut provider_models { model.provider = name.clone(); } models.extend(provider_models); } Err(err) => errors.push(format!("{}: {}", name, err)), }, Err(err) => errors.push(format!("{}: {}", name, err)), } } // Sort models alphabetically by name for a predictable UI order models.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); (models, errors) } fn recompute_available_providers(&mut self) { let mut providers: BTreeSet = self.controller.config().providers.keys().cloned().collect(); providers.extend(self.models.iter().map(|m| m.provider.clone())); if providers.is_empty() { providers.insert(self.selected_provider.clone()); } if providers.is_empty() { providers.insert("ollama".to_string()); } self.available_providers = providers.into_iter().collect(); } fn rebuild_model_selector_items(&mut self) { let mut items = Vec::new(); if self.available_providers.is_empty() { items.push(ModelSelectorItem::header("ollama", false)); self.model_selector_items = items; return; } let expanded = self.expanded_provider.clone(); for provider in &self.available_providers { let is_expanded = expanded.as_ref().map(|p| p == provider).unwrap_or(false); items.push(ModelSelectorItem::header(provider.clone(), is_expanded)); if is_expanded { let mut matches: Vec<(usize, &ModelInfo)> = self .models .iter() .enumerate() .filter(|(_, model)| &model.provider == provider) .collect(); matches.sort_by(|(_, a), (_, b)| a.id.cmp(&b.id)); if matches.is_empty() { items.push(ModelSelectorItem::empty(provider.clone())); } else { for (idx, _) in matches { items.push(ModelSelectorItem::model(provider.clone(), idx)); } } } } self.model_selector_items = items; self.ensure_valid_model_selection(); } fn first_model_item_index(&self) -> Option { self.model_selector_items .iter() .enumerate() .find(|(_, item)| item.is_model()) .map(|(idx, _)| idx) } fn index_of_header(&self, provider: &str) -> Option { self.model_selector_items .iter() .enumerate() .find(|(_, item)| item.provider_if_header() == Some(provider)) .map(|(idx, _)| idx) } fn index_of_first_model_for_provider(&self, provider: &str) -> Option { self.model_selector_items .iter() .enumerate() .find(|(_, item)| { matches!( item.kind(), ModelSelectorItemKind::Model { provider: ref p, .. } if p == provider ) }) .map(|(idx, _)| idx) } fn index_of_model_id(&self, model_id: &str) -> Option { self.model_selector_items .iter() .enumerate() .find(|(_, item)| { item.model_index() .and_then(|idx| self.models.get(idx)) .map(|model| model.id == model_id) .unwrap_or(false) }) .map(|(idx, _)| idx) } fn selected_model_info(&self) -> Option<&ModelInfo> { self.selected_model_item .and_then(|idx| self.model_selector_items.get(idx)) .and_then(|item| item.model_index()) .and_then(|model_index| self.models.get(model_index)) } fn current_model_selector_item(&self) -> Option<&ModelSelectorItem> { self.selected_model_item .and_then(|idx| self.model_selector_items.get(idx)) } fn set_selected_model_item(&mut self, index: usize) { if self.model_selector_items.is_empty() { self.selected_model_item = None; return; } let clamped = index.min(self.model_selector_items.len().saturating_sub(1)); self.selected_model_item = Some(clamped); if let Some(item) = self.model_selector_items.get(clamped) { match item.kind() { ModelSelectorItemKind::Header { provider, .. } | ModelSelectorItemKind::Model { provider, .. } | ModelSelectorItemKind::Empty { provider } => { self.selected_provider = provider.clone(); self.update_selected_provider_index(); } } } } fn ensure_valid_model_selection(&mut self) { if self.model_selector_items.is_empty() { self.selected_model_item = None; return; } let needs_reset = self .selected_model_item .map(|idx| idx >= self.model_selector_items.len()) .unwrap_or(true); if needs_reset { self.set_selected_model_item(0); } else if let Some(idx) = self.selected_model_item { self.set_selected_model_item(idx); } } fn move_model_selection(&mut self, direction: i32) { if self.model_selector_items.is_empty() { self.selected_model_item = None; return; } let len = self.model_selector_items.len() as isize; let mut idx = self.selected_model_item.unwrap_or(0) as isize + direction as isize; if idx < 0 { idx = 0; } else if idx >= len { idx = len - 1; } self.set_selected_model_item(idx as usize); } fn update_selected_provider_index(&mut self) { if let Some(idx) = self .available_providers .iter() .position(|p| p == &self.selected_provider) { self.selected_provider_index = idx; } else if !self.available_providers.is_empty() { self.selected_provider_index = 0; self.selected_provider = self.available_providers[0].clone(); } } fn expand_provider(&mut self, provider: &str, focus_first_model: bool) { let provider_owned = provider.to_string(); let needs_rebuild = self.expanded_provider.as_deref() != Some(provider); self.selected_provider = provider_owned.clone(); self.expanded_provider = Some(provider_owned.clone()); if needs_rebuild { self.rebuild_model_selector_items(); } self.ensure_valid_model_selection(); if focus_first_model { if let Some(idx) = self.index_of_first_model_for_provider(&provider_owned) { self.set_selected_model_item(idx); } else if let Some(idx) = self.index_of_header(&provider_owned) { self.set_selected_model_item(idx); } } else if let Some(idx) = self.index_of_header(&provider_owned) { self.set_selected_model_item(idx); } } fn collapse_provider(&mut self, provider: &str) { if self.expanded_provider.as_deref() == Some(provider) { self.expanded_provider = None; self.rebuild_model_selector_items(); if let Some(idx) = self.index_of_header(provider) { self.set_selected_model_item(idx); } } } async fn switch_to_provider(&mut self, provider_name: &str) -> Result<()> { if self.current_provider == provider_name { return Ok(()); } let provider_cfg = if let Some(cfg) = self.controller.config().provider(provider_name) { cfg.clone() } else { let mut guard = self.controller.config_mut(); // Pass a mutable reference directly; avoid unnecessary deref let cfg = config::ensure_provider_config(&mut guard, provider_name); cfg.clone() }; // All providers use MCP architecture (Phase 10). // For cloud providers, pass the URL via environment variable. let provider: Arc = if provider_cfg .provider_type .eq_ignore_ascii_case("ollama-cloud") { // Cloud Ollama - create MCP client with custom URL use owlen_core::config::McpServerConfig; use std::collections::HashMap; let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../..") .canonicalize()?; let binary_path = [ "target/debug/owlen-mcp-llm-server", "target/release/owlen-mcp-llm-server", ] .iter() .map(|rel| workspace_root.join(rel)) .find(|p| p.exists()) .ok_or_else(|| anyhow::anyhow!("MCP LLM server binary not found"))?; let mut env_vars = HashMap::new(); if let Some(url) = &provider_cfg.base_url { env_vars.insert("OLLAMA_URL".to_string(), url.clone()); } let config = McpServerConfig { name: provider_name.to_string(), command: binary_path.to_string_lossy().into_owned(), args: Vec::new(), transport: "stdio".to_string(), env: env_vars, }; Arc::new(RemoteMcpClient::new_with_config(&config)?) } else { // Local Ollama via default MCP client Arc::new(RemoteMcpClient::new()?) }; self.controller.switch_provider(provider).await?; self.current_provider = provider_name.to_string(); Ok(()) } async fn refresh_models(&mut self) -> Result<()> { let config_model_name = self.controller.config().general.default_model.clone(); let config_model_provider = self.controller.config().general.default_provider.clone(); let (all_models, errors) = self.collect_models_from_all_providers().await; if all_models.is_empty() { self.error = if errors.is_empty() { Some("No models available".to_string()) } else { Some(errors.join("; ")) }; self.models.clear(); self.recompute_available_providers(); if self.available_providers.is_empty() { self.available_providers.push("ollama".to_string()); } self.rebuild_model_selector_items(); self.selected_model_item = None; self.status = "No models available".to_string(); self.update_selected_provider_index(); return Ok(()); } self.models = all_models; self.recompute_available_providers(); if self.available_providers.is_empty() { self.available_providers.push("ollama".to_string()); } if !config_model_provider.is_empty() { self.selected_provider = config_model_provider.clone(); } else { self.selected_provider = self.available_providers[0].clone(); } self.expanded_provider = Some(self.selected_provider.clone()); self.update_selected_provider_index(); // Ensure the default model is set after refreshing models (async) self.controller.ensure_default_model(&self.models).await; self.sync_selected_model_index().await; let current_model_name = self.controller.selected_model().to_string(); let current_model_provider = self.controller.config().general.default_provider.clone(); if config_model_name.as_deref() != Some(¤t_model_name) || config_model_provider != current_model_provider { if let Err(err) = config::save_config(&self.controller.config()) { self.error = Some(format!("Failed to save config: {err}")); } else { self.error = None; } } if !errors.is_empty() { self.error = Some(errors.join("; ")); } else { self.error = None; } self.status = format!( "Loaded {} models across {} provider(s)", self.models.len(), self.available_providers.len() ); Ok(()) } fn send_user_message_and_request_response(&mut self) { let content = self.controller.input_buffer().text().trim().to_string(); if content.is_empty() { self.error = Some("Cannot send empty message".to_string()); return; } // Step 1: Add user message to conversation immediately (synchronous) let message = self.controller.input_buffer_mut().commit_to_history(); self.controller .conversation_mut() .push_user_message(message.clone()); // Auto-scroll to bottom when sending a message self.auto_scroll.stick_to_bottom = true; // Step 2: Set flag to process LLM request on next event loop iteration self.pending_llm_request = true; self.status = "Message sent".to_string(); self.error = None; } pub async fn process_pending_llm_request(&mut self) -> Result<()> { if !self.pending_llm_request { return Ok(()); } self.pending_llm_request = false; // Check if agent mode is enabled if self.agent_mode { return self.process_agent_request().await; } // Step 1: Show loading model status and start animation self.status = format!("Loading model '{}'...", self.controller.selected_model()); self.start_loading_animation(); let parameters = ChatParameters { stream: self.controller.config().general.enable_streaming, ..Default::default() }; // Add a timeout to prevent indefinite blocking let request_future = self .controller .send_request_with_current_conversation(parameters); let timeout_duration = std::time::Duration::from_secs(30); match tokio::time::timeout(timeout_duration, request_future).await { Ok(Ok(SessionOutcome::Complete(_response))) => { self.stop_loading_animation(); self.status = "Ready".to_string(); self.error = None; Ok(()) } Ok(Ok(SessionOutcome::Streaming { response_id, stream, })) => { self.status = "Model loaded. Generating response... (streaming)".to_string(); self.spawn_stream(response_id, stream); match self.controller.mark_stream_placeholder(response_id, "▌") { Ok(_) => self.error = None, Err(err) => { self.error = Some(format!("Could not set response placeholder: {}", err)); } } Ok(()) } Ok(Err(err)) => { let message = err.to_string(); if message.to_lowercase().contains("not found") { self.error = Some( "Model not available. Press 'm' to pick another installed model." .to_string(), ); self.status = "Model unavailable".to_string(); let _ = self.refresh_models().await; self.mode = InputMode::ProviderSelection; } else { self.error = Some(message); self.status = "Request failed".to_string(); } self.stop_loading_animation(); Ok(()) } Err(_) => { self.error = Some("Request timed out. Check if Ollama is running.".to_string()); self.status = "Request timed out".to_string(); self.stop_loading_animation(); Ok(()) } } } async fn process_agent_request(&mut self) -> Result<()> { use owlen_core::agent::{AgentConfig, AgentExecutor}; use owlen_core::mcp::remote_client::RemoteMcpClient; use std::sync::Arc; self.agent_running = true; self.status = "Agent is running...".to_string(); self.start_loading_animation(); // Get the last user message let user_message = self .controller .conversation() .messages .iter() .rev() .find(|m| m.role == owlen_core::types::Role::User) .map(|m| m.content.clone()) .unwrap_or_default(); // Create agent config let config = AgentConfig { max_iterations: 10, model: self.controller.selected_model().to_string(), temperature: Some(0.7), max_tokens: None, }; // Get the provider let provider = self.controller.provider().clone(); // Create MCP client let mcp_client = match RemoteMcpClient::new() { Ok(client) => Arc::new(client), Err(e) => { self.error = Some(format!("Failed to initialize MCP client: {}", e)); self.agent_running = false; self.agent_mode = false; self.stop_loading_animation(); return Ok(()); } }; // Create agent executor let executor = AgentExecutor::new(provider, mcp_client, config); // Run agent match executor.run(user_message).await { Ok(result) => { self.controller .conversation_mut() .push_assistant_message(result.answer); self.agent_running = false; self.agent_mode = false; self.agent_actions = None; self.status = format!("Agent completed in {} iterations", result.iterations); self.stop_loading_animation(); Ok(()) } Err(e) => { self.error = Some(format!("Agent failed: {}", e)); self.agent_running = false; self.agent_mode = false; self.agent_actions = None; self.stop_loading_animation(); Ok(()) } } } pub async fn process_pending_tool_execution(&mut self) -> Result<()> { if self.pending_tool_execution.is_none() { return Ok(()); } let (message_id, tool_calls) = self.pending_tool_execution.take().unwrap(); // Check if consent is needed for any of these tools let consent_needed = self.controller.check_tools_consent_needed(&tool_calls); if !consent_needed.is_empty() { // If a consent dialog is already being shown, don't send another request // Just re-queue the tool execution and wait for user response if self.pending_consent.is_some() { self.pending_tool_execution = Some((message_id, tool_calls)); return Ok(()); } // Show consent for the first tool that needs it // After consent is granted, the next iteration will check remaining tools let (tool_name, data_types, endpoints) = consent_needed.into_iter().next().unwrap(); let callback_id = Uuid::new_v4(); let sender = self.session_tx.clone(); let _ = sender.send(SessionEvent::ConsentNeeded { tool_name, data_types, endpoints, callback_id, }); // Re-queue the tool execution for after consent is granted self.pending_tool_execution = Some((message_id, tool_calls)); return Ok(()); } // Show tool execution status self.status = format!("🔧 Executing {} tool(s)...", tool_calls.len()); // Show tool names in system output let tool_names: Vec = tool_calls.iter().map(|tc| tc.name.clone()).collect(); self.set_system_status(format!("🔧 Executing tools: {}", tool_names.join(", "))); self.start_loading_animation(); // Execute tools and get the result match self .controller .execute_streaming_tools(message_id, tool_calls) .await { Ok(SessionOutcome::Streaming { response_id, stream, }) => { // Tool execution succeeded, spawn stream handler for continuation self.status = "Tool results sent. Generating response...".to_string(); self.set_system_status("✓ Tools executed successfully".to_string()); self.spawn_stream(response_id, stream); match self.controller.mark_stream_placeholder(response_id, "▌") { Ok(_) => self.error = None, Err(err) => { self.error = Some(format!("Could not set response placeholder: {}", err)); } } Ok(()) } Ok(SessionOutcome::Complete(_response)) => { // Tool execution complete without streaming (shouldn't happen in streaming mode) self.stop_loading_animation(); self.status = "✓ Tool execution complete".to_string(); self.set_system_status("✓ Tool execution complete".to_string()); self.error = None; Ok(()) } Err(err) => { self.stop_loading_animation(); self.status = "Tool execution failed".to_string(); self.set_system_status(format!("❌ Tool execution failed: {}", err)); self.error = Some(format!("Tool execution failed: {}", err)); Ok(()) } } } // Updated to async to allow awaiting async controller calls async fn sync_selected_model_index(&mut self) { self.expanded_provider = Some(self.selected_provider.clone()); self.rebuild_model_selector_items(); let current_model_id = self.controller.selected_model().to_string(); let mut config_updated = false; if let Some(idx) = self.index_of_model_id(¤t_model_id) { self.set_selected_model_item(idx); } else { if let Some(idx) = self.index_of_first_model_for_provider(&self.selected_provider) { self.set_selected_model_item(idx); } else if let Some(idx) = self.index_of_header(&self.selected_provider) { self.set_selected_model_item(idx); } else if let Some(idx) = self.first_model_item_index() { self.set_selected_model_item(idx); } else { self.ensure_valid_model_selection(); } if let Some(model) = self.selected_model_info().cloned() { self.selected_provider = model.provider.clone(); // Set the selected model asynchronously self.controller.set_model(model.id.clone()).await; self.controller.config_mut().general.default_model = Some(model.id.clone()); self.controller.config_mut().general.default_provider = self.selected_provider.clone(); config_updated = true; } } self.update_selected_provider_index(); if config_updated { if let Err(err) = config::save_config(&self.controller.config()) { self.error = Some(format!("Failed to save config: {err}")); } else { self.error = None; } } } pub fn set_viewport_dimensions(&mut self, height: usize, content_width: usize) { self.viewport_height = height; self.content_width = content_width; } pub fn set_thinking_viewport_height(&mut self, height: usize) { self.thinking_viewport_height = height; } pub fn start_loading_animation(&mut self) { self.is_loading = true; self.loading_animation_frame = 0; } pub fn stop_loading_animation(&mut self) { self.is_loading = false; } pub fn advance_loading_animation(&mut self) { if self.is_loading { self.loading_animation_frame = (self.loading_animation_frame + 1) % 8; // 8-frame animation } } pub fn get_loading_indicator(&self) -> &'static str { if !self.is_loading { return ""; } match self.loading_animation_frame { 0 => "⠋", 1 => "⠙", 2 => "⠹", 3 => "⠸", 4 => "⠼", 5 => "⠴", 6 => "⠦", 7 => "⠧", _ => "⠋", } } pub fn current_thinking(&self) -> Option<&String> { self.current_thinking.as_ref() } /// Get a reference to the latest agent actions, if any. pub fn agent_actions(&self) -> Option<&String> { self.agent_actions.as_ref() } /// Set the current agent actions content. pub fn set_agent_actions(&mut self, actions: String) { self.agent_actions = Some(actions); } /// Check if agent mode is enabled pub fn is_agent_mode(&self) -> bool { self.agent_mode } /// Check if agent is currently running pub fn is_agent_running(&self) -> bool { self.agent_running } pub fn get_rendered_lines(&self) -> Vec { match self.focused_panel { FocusedPanel::Chat => { // This should match exactly what render_messages produces let conversation = self.conversation(); let formatter = self.formatter(); let mut lines = Vec::new(); for (message_index, message) in conversation.messages.iter().enumerate() { let role = &message.role; let (emoji, name) = match role { Role::User => ("👤 ", "You: "), Role::Assistant => ("🤖 ", "Assistant: "), Role::System => ("⚙️ ", "System: "), Role::Tool => ("🔧 ", "Tool: "), }; let content_to_display = if matches!(role, Role::Assistant) { let (content_without_think, _) = formatter.extract_thinking(&message.content); content_without_think } else { message.content.clone() }; // Add role label line lines.push(format!("{}{}", emoji, name)); // Add content lines with indent for line in content_to_display.trim().lines() { lines.push(format!(" {}", line)); } // Add separator except after last message if message_index < conversation.messages.len() - 1 { lines.push(String::new()); } } lines } FocusedPanel::Thinking => { if let Some(thinking) = &self.current_thinking { thinking.lines().map(|s| s.to_string()).collect() } else { Vec::new() } } FocusedPanel::Input => Vec::new(), } } fn get_line_at_row(&self, row: usize) -> Option { self.get_rendered_lines().get(row).cloned() } fn find_next_word_boundary(&self, row: usize, col: usize) -> Option { let line = self.get_line_at_row(row)?; owlen_core::ui::find_next_word_boundary(&line, col) } fn find_word_end(&self, row: usize, col: usize) -> Option { let line = self.get_line_at_row(row)?; owlen_core::ui::find_word_end(&line, col) } fn find_prev_word_boundary(&self, row: usize, col: usize) -> Option { let line = self.get_line_at_row(row)?; owlen_core::ui::find_prev_word_boundary(&line, col) } fn yank_from_panel(&self) -> Option { let (start_pos, end_pos) = if let (Some(s), Some(e)) = (self.visual_start, self.visual_end) { // Normalize selection if s.0 < e.0 || (s.0 == e.0 && s.1 <= e.1) { (s, e) } else { (e, s) } } else { return None; }; let lines = self.get_rendered_lines(); owlen_core::ui::extract_text_from_selection(&lines, start_pos, end_pos) } pub fn update_thinking_from_last_message(&mut self) { // Extract thinking from the last assistant message if let Some(last_msg) = self .conversation() .messages .iter() .rev() .find(|m| matches!(m.role, Role::Assistant)) { let (_, thinking) = self.formatter().extract_thinking(&last_msg.content); // Only set stick_to_bottom if content actually changed (to enable auto-scroll during streaming) let content_changed = self.current_thinking != thinking; self.current_thinking = thinking; if content_changed { // Auto-scroll thinking panel to bottom when content updates self.thinking_scroll.stick_to_bottom = true; } } else { self.current_thinking = None; // If thinking panel was focused but thinking disappeared, switch to Chat if matches!(self.focused_panel, FocusedPanel::Thinking) { self.focused_panel = FocusedPanel::Chat; } } } fn spawn_stream(&mut self, message_id: Uuid, mut stream: owlen_core::provider::ChatStream) { let sender = self.session_tx.clone(); self.streaming.insert(message_id); tokio::spawn(async move { use futures_util::StreamExt; while let Some(item) = stream.next().await { match item { Ok(response) => { if sender .send(SessionEvent::StreamChunk { message_id, response, }) .is_err() { break; } } Err(e) => { let _ = sender.send(SessionEvent::StreamError { message: e.to_string(), }); break; } } } }); } } fn configure_textarea_defaults(textarea: &mut TextArea<'static>) { textarea.set_placeholder_text("Type your message here..."); textarea.set_tab_length(4); textarea.set_style( Style::default() .remove_modifier(Modifier::UNDERLINED) .remove_modifier(Modifier::ITALIC) .remove_modifier(Modifier::BOLD), ); textarea.set_cursor_style(Style::default()); textarea.set_cursor_line_style(Style::default()); }