use anyhow::{Context, Result, anyhow}; use async_trait::async_trait; use chrono::{DateTime, Local, Utc}; use crossterm::{ event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind}, terminal::{disable_raw_mode, enable_raw_mode}, }; use owlen_core::Error as CoreError; use owlen_core::consent::ConsentScope; use owlen_core::facade::llm_client::LlmClient; use owlen_core::mcp::remote_client::RemoteMcpClient; use owlen_core::mcp::{McpToolDescriptor, McpToolResponse}; use owlen_core::provider::{ AnnotatedModelInfo, ModelInfo as ProviderModelInfo, ProviderMetadata, ProviderStatus, ProviderType, }; use owlen_core::{ ProviderConfig, config::McpResourceConfig, model::DetailedModelInfo, oauth::{DeviceAuthorization, DevicePollState}, session::{ControllerEvent, SessionController, SessionOutcome, ToolConsentResolution}, storage::SessionMeta, theme::Theme, types::{ChatParameters, ChatResponse, Conversation, ModelInfo, Role, TokenUsage}, ui::{AppState, AutoScroll, FocusedPanel, InputMode, RoleLabelDisplay}, usage::{UsageBand, UsageSnapshot, UsageWindow, WindowMetrics}, }; use owlen_markdown::from_str; use pathdiff::diff_paths; use ratatui::{ layout::Rect, style::{Color, Modifier, Style}, text::{Line, Span}, }; use textwrap::{Options, WordSeparator, wrap}; use tokio::{ sync::mpsc, task::{self, JoinHandle}, }; use tui_textarea::{CursorMove, Input, TextArea}; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; use uuid::Uuid; use crate::app::{ MessageState, UiRuntime, mvu::{self, AppEffect, AppEvent, AppModel, ComposerEvent, SubmissionOutcome}, }; use crate::commands::{AppCommand, CommandRegistry}; use crate::config; use crate::events::Event; use crate::model_info_panel::ModelInfoPanel; use crate::slash::{self, McpSlashCommand, SlashCommand}; use crate::state::{ CodeWorkspace, CommandPalette, DebugLogEntry, DebugLogState, FileFilterMode, FileIconResolver, FileNode, FileTreeState, Keymap, KeymapProfile, ModelPaletteEntry, PaletteSuggestion, PaneDirection, PaneRestoreRequest, RepoSearchMessage, RepoSearchState, SplitAxis, SymbolSearchMessage, SymbolSearchState, WorkspaceSnapshot, install_global_logger, spawn_repo_search_task, spawn_symbol_search_task, }; use crate::toast::{Toast, ToastLevel, ToastManager}; use crate::ui::{format_token_short, format_tool_output}; use crate::widgets::model_picker::FilterMode; use crate::{commands, highlight}; use owlen_core::config::{ LEGACY_OLLAMA_CLOUD_API_KEY_ENV, LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV, OLLAMA_API_KEY_ENV, OLLAMA_CLOUD_BASE_URL, OLLAMA_CLOUD_ENDPOINT_KEY, OLLAMA_MODE_KEY, }; use owlen_core::credentials::{ApiCredentials, OLLAMA_CLOUD_CREDENTIAL_ID}; // 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::hash_map::DefaultHasher; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}; use std::env; use std::fs; use std::fs::OpenOptions; use std::hash::{Hash, Hasher}; use std::path::{Component, Path, PathBuf}; use std::process::Command; use std::sync::Arc; use std::time::{Duration, Instant, SystemTime}; use dirs::{config_dir, data_local_dir}; use log::Level; use serde_json::{Value, json}; const ONBOARDING_STATUS_LINE: &str = "Welcome to Owlen! Press F1 for help or type :tutorial for keybinding tips."; const ONBOARDING_SYSTEM_STATUS: &str = "Normal ▸ h/j/k/l • Insert ▸ i,a • Visual ▸ v • Command ▸ : • Help ▸ F1/?"; const TUTORIAL_STATUS: &str = "Tutorial loaded. Review quick tips in the footer."; const TUTORIAL_SYSTEM_STATUS: &str = "Normal ▸ h/j/k/l • Insert ▸ i,a • Visual ▸ v • Command ▸ : • Help ▸ F1/? • Send ▸ Enter"; const DEFAULT_CLOUD_ENDPOINT: &str = OLLAMA_CLOUD_BASE_URL; const FOCUS_CHORD_TIMEOUT: Duration = Duration::from_millis(1200); const RESIZE_DOUBLE_TAP_WINDOW: Duration = Duration::from_millis(450); const RESIZE_STEP: f32 = 0.05; const RESIZE_SNAP_VALUES: [f32; 3] = [0.5, 0.75, 0.25]; const DOUBLE_CTRL_C_WINDOW: Duration = Duration::from_millis(1500); pub(crate) const MIN_MESSAGE_CARD_WIDTH: usize = 14; const MOUSE_SCROLL_STEP: isize = 3; const DEFAULT_CONTEXT_WINDOW_TOKENS: u32 = 8_192; #[derive(Clone, Copy, Debug, Default)] pub struct ContextUsage { pub prompt_tokens: u32, pub completion_tokens: u32, pub context_window: u32, } #[derive(Clone, Copy, Debug)] pub(crate) struct LayoutSnapshot { pub(crate) frame: Rect, pub(crate) content: Rect, pub(crate) header_panel: Option, pub(crate) file_panel: Option, pub(crate) chat_panel: Option, pub(crate) thinking_panel: Option, pub(crate) actions_panel: Option, pub(crate) input_panel: Option, pub(crate) system_panel: Option, pub(crate) status_panel: Option, pub(crate) code_panel: Option, pub(crate) model_info_panel: Option, } impl LayoutSnapshot { pub(crate) fn new(frame: Rect, content: Rect) -> Self { Self { frame, content, header_panel: None, file_panel: None, chat_panel: None, thinking_panel: None, actions_panel: None, input_panel: None, system_panel: None, status_panel: None, code_panel: None, model_info_panel: None, } } fn contains(rect: Rect, column: u16, row: u16) -> bool { let x_end = rect.x.saturating_add(rect.width); let y_end = rect.y.saturating_add(rect.height); column >= rect.x && column < x_end && row >= rect.y && row < y_end } fn region_at(&self, column: u16, row: u16) -> Option { if let Some(rect) = self.model_info_panel { if Self::contains(rect, column, row) { return Some(UiRegion::ModelInfo); } } if let Some(rect) = self.header_panel { if Self::contains(rect, column, row) { return Some(UiRegion::Header); } } if let Some(rect) = self.code_panel { if Self::contains(rect, column, row) { return Some(UiRegion::Code); } } if let Some(rect) = self.file_panel { if Self::contains(rect, column, row) { return Some(UiRegion::FileTree); } } if let Some(rect) = self.input_panel { if Self::contains(rect, column, row) { return Some(UiRegion::Input); } } if let Some(rect) = self.system_panel { if Self::contains(rect, column, row) { return Some(UiRegion::System); } } if let Some(rect) = self.status_panel { if Self::contains(rect, column, row) { return Some(UiRegion::Status); } } if let Some(rect) = self.actions_panel { if Self::contains(rect, column, row) { return Some(UiRegion::Actions); } } if let Some(rect) = self.thinking_panel { if Self::contains(rect, column, row) { return Some(UiRegion::Thinking); } } if let Some(rect) = self.chat_panel { if Self::contains(rect, column, row) { return Some(UiRegion::Chat); } } if Self::contains(self.content, column, row) { Some(UiRegion::Content) } else if Self::contains(self.frame, column, row) { Some(UiRegion::Frame) } else { None } } } impl Default for LayoutSnapshot { fn default() -> Self { Self::new(Rect::new(0, 0, 0, 0), Rect::new(0, 0, 0, 0)) } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum UiRegion { Header, Frame, Content, FileTree, Chat, Thinking, Actions, Input, System, Status, Code, ModelInfo, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum SlashOutcome { NotCommand, Consumed, Error, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum SaveStatus { Saved, NoChanges, Failed, } #[derive(Clone, Debug)] pub(crate) struct ModelSelectorItem { kind: ModelSelectorItemKind, } #[derive(Clone, Debug)] pub(crate) struct HighlightMask { bits: Vec, } impl HighlightMask { fn new(bits: Vec) -> Self { Self { bits } } pub(crate) fn is_marked(&self) -> bool { self.bits.iter().any(|b| *b) } pub(crate) fn bits(&self) -> &[bool] { &self.bits } pub(crate) fn truncated(&self, len: usize) -> Self { let len = len.min(self.bits.len()); Self::new(self.bits[..len].to_vec()) } } #[derive(Clone, Debug, Default)] pub(crate) struct ModelSearchInfo { pub(crate) score: (usize, usize), pub(crate) name: Option, pub(crate) id: Option, pub(crate) provider: Option, pub(crate) description: Option, } #[derive(Clone, Debug)] pub(crate) enum ModelSelectorItemKind { Header { provider: String, expanded: bool, status: ProviderStatus, provider_type: ProviderType, }, Scope { provider: String, label: String, scope: ModelScope, status: ModelAvailabilityState, }, Model { provider: String, model_index: usize, }, Empty { provider: String, message: Option, status: Option, }, } impl ModelSelectorItem { fn header( provider: impl Into, expanded: bool, status: ProviderStatus, provider_type: ProviderType, ) -> Self { Self { kind: ModelSelectorItemKind::Header { provider: provider.into(), expanded, status, provider_type, }, } } fn scope( provider: impl Into, label: impl Into, scope: ModelScope, status: ModelAvailabilityState, ) -> Self { Self { kind: ModelSelectorItemKind::Scope { provider: provider.into(), label: label.into(), scope, status, }, } } fn model(provider: impl Into, model_index: usize) -> Self { Self { kind: ModelSelectorItemKind::Model { provider: provider.into(), model_index, }, } } fn empty( provider: impl Into, message: Option, status: Option, ) -> Self { Self { kind: ModelSelectorItemKind::Empty { provider: provider.into(), message, status, }, } } 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, .. } | ModelSelectorItemKind::Scope { provider, .. } => Some(provider), _ => None, } } pub(crate) fn kind(&self) -> &ModelSelectorItemKind { &self.kind } } fn collect_lower_graphemes(text: &str) -> (Vec<&str>, Vec) { let graphemes: Vec<&str> = UnicodeSegmentation::graphemes(text, true).collect(); let lower: Vec = graphemes.iter().map(|g| g.to_lowercase()).collect(); (graphemes, lower) } fn subsequence_highlight(candidate: &[String], query: &[String]) -> Option> { if query.is_empty() { return None; } let mut mask = vec![false; candidate.len()]; let mut q_idx = 0usize; for (idx, g) in candidate.iter().enumerate() { if q_idx < query.len() && g == &query[q_idx] { mask[idx] = true; q_idx += 1; } } if q_idx == query.len() { Some(mask) } else { None } } fn search_candidate(candidate: &str, query: &str) -> Option<((usize, usize), HighlightMask)> { let candidate = candidate.trim(); let query = query.trim(); if candidate.is_empty() || query.is_empty() { return None; } let (original_graphemes, lower_graphemes) = collect_lower_graphemes(candidate); let candidate_lower = lower_graphemes.join(""); let query_lower = query.to_lowercase(); let query_graphemes: Vec = UnicodeSegmentation::graphemes(query_lower.as_str(), true) .map(|g| g.to_string()) .collect(); let query_len = query_graphemes.len(); let mut mask = vec![false; original_graphemes.len()]; if candidate_lower == query_lower { mask.fill(true); return Some(((0, candidate.len()), HighlightMask::new(mask))); } if candidate_lower.starts_with(&query_lower) { for idx in 0..query_len.min(mask.len()) { mask[idx] = true; } return Some(((1, 0), HighlightMask::new(mask))); } if let Some(start_byte) = candidate_lower.find(&query_lower) { let mut collected_bytes = 0usize; let mut start_index = 0usize; for (idx, grapheme) in lower_graphemes.iter().enumerate() { if collected_bytes == start_byte { start_index = idx; break; } collected_bytes += grapheme.len(); } for idx in start_index..(start_index + query_len).min(mask.len()) { mask[idx] = true; } return Some(((2, start_byte), HighlightMask::new(mask))); } if let Some(subsequence_mask) = subsequence_highlight(&lower_graphemes, &query_graphemes) { if subsequence_mask.iter().any(|b| *b) { return Some(((3, candidate.len()), HighlightMask::new(subsequence_mask))); } } None } #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] pub(crate) enum ModelScope { Local, Cloud, Other(String), } #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] pub(crate) enum ModelAvailabilityState { Unknown, Available, Unavailable, } impl Default for ModelAvailabilityState { fn default() -> Self { Self::Unknown } } #[derive(Clone, Debug, Default)] pub(crate) struct ScopeStatusEntry { pub state: ModelAvailabilityState, pub message: Option, pub last_checked_secs: Option, pub last_success_secs: Option, pub is_stale: bool, } pub(crate) type ProviderScopeStatus = BTreeMap; /// Messages emitted by asynchronous streaming tasks #[derive(Debug)] pub enum SessionEvent { StreamChunk { message_id: Uuid, response: ChatResponse, }, StreamError { message_id: Option, message: String, }, ToolExecutionNeeded { message_id: Uuid, tool_calls: Vec, }, /// 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 }, /// Poll the OAuth device authorization flow for the given server OAuthPoll { server: String, authorization: DeviceAuthorization, }, } pub const HELP_TAB_COUNT: usize = 7; pub struct ChatApp { controller: SessionController, pub mode: InputMode, mode_flash_until: Option, pub status: String, pub error: Option, models: Vec, // All models fetched annotated_models: Vec, // Models annotated with provider metadata provider_scope_status: HashMap, 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 model_filter_mode: FilterMode, // Active filter applied to the model list model_filter_memory: FilterMode, // Last user-selected filter mode model_search_query: String, // Active fuzzy search query for the picker model_search_hits: HashMap, // Cached search metadata per model index provider_search_hits: HashMap, // Cached search highlight per provider visible_model_count: usize, // Number of visible models in current selector view model_info_panel: ModelInfoPanel, // Dedicated model information viewer model_details_cache: HashMap, // Cached detailed metadata per model show_model_info: bool, // Whether the model info panel is visible model_info_viewport_height: usize, // Cached viewport height for the info panel expanded_provider: Option, // Which provider group is currently expanded current_provider: String, // Provider backing the active session message_line_cache: HashMap, // Cached rendered lines per message show_cursor_outside_insert: bool, // Configurable cursor visibility flag syntax_highlighting: bool, // Whether syntax highlighting is enabled render_markdown: bool, // Whether markdown rendering is enabled show_message_timestamps: bool, // Whether to render timestamps in chat headers 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, stream_tasks: HashMap>, textarea: TextArea<'static>, // Advanced text input widget mvu_model: AppModel, keymap: Keymap, current_keymap_profile: KeymapProfile, controller_event_rx: mpsc::UnboundedReceiver, 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 pending_file_action: Option, // Active file action prompt command_palette: CommandPalette, // Command mode state (buffer + suggestions) resource_catalog: Vec, // Configured MCP resources for autocompletion pending_resource_refs: Vec, // Resource references to resolve before send oauth_flows: HashMap, // Active OAuth device flows by server repo_search: RepoSearchState, // Repository search overlay state repo_search_task: Option>, repo_search_rx: Option>, repo_search_file_map: HashMap, symbol_search: SymbolSearchState, // Symbol search overlay state symbol_search_task: Option>, symbol_search_rx: Option>, 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) chat_line_offset: usize, // Number of leading lines trimmed for scrollback thinking_cursor: (usize, usize), // Cursor position in Thinking panel (row, col) code_workspace: CodeWorkspace, // Code views with tabs/splits pending_focus_chord: Option, // Tracks Ctrl+K focus chord timeout last_resize_tap: Option<(PaneDirection, Instant)>, // For Alt+arrow double-tap detection resize_snap_index: usize, // Cycles through 25/50/75 snaps last_snap_direction: Option, last_ctrl_c: Option, // Track timing for double Ctrl+C quit file_tree: FileTreeState, // Workspace file tree state file_icons: FileIconResolver, // Icon resolver with Nerd/ASCII fallback file_panel_collapsed: bool, // Whether the file panel is collapsed file_panel_width: u16, // Cached file panel width 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 queued_consents: VecDeque, // Backlog of consent requests system_status: String, // System/status messages (tool execution, status, etc) toasts: ToastManager, debug_log: DebugLogState, usage_snapshot: Option, usage_thresholds: HashMap<(String, UsageWindow), UsageBand>, context_usage: Option, last_layout: LayoutSnapshot, /// 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, /// Flag indicating new messages arrived while scrolled away from tail new_message_alert: bool, } #[derive(Clone, Debug)] pub struct ConsentDialogState { pub request_id: Uuid, pub tool_name: String, pub data_types: Vec, pub endpoints: Vec, pub message_id: Uuid, pub tool_calls: Vec, } #[derive(Clone)] struct MessageCacheEntry { theme_name: String, wrap_width: usize, role_label_mode: RoleLabelDisplay, syntax_highlighting: bool, render_markdown: bool, show_timestamps: bool, content_hash: u64, lines: Vec>, metrics: MessageLayoutMetrics, } #[derive(Clone, Debug, Default)] struct MessageLayoutMetrics { line_count: usize, body_width: usize, card_width: usize, } pub(crate) struct MessageRenderContext<'a> { formatter: &'a mut owlen_core::formatting::MessageFormatter, role_label_mode: RoleLabelDisplay, body_width: usize, card_width: usize, is_streaming: bool, loading_indicator: &'a str, theme: &'a Theme, syntax_highlighting: bool, render_markdown: bool, } impl<'a> MessageRenderContext<'a> { #[allow(clippy::too_many_arguments)] pub(crate) fn new( formatter: &'a mut owlen_core::formatting::MessageFormatter, role_label_mode: RoleLabelDisplay, body_width: usize, card_width: usize, is_streaming: bool, loading_indicator: &'a str, theme: &'a Theme, syntax_highlighting: bool, render_markdown: bool, ) -> Self { Self { formatter, role_label_mode, body_width, card_width, is_streaming, loading_indicator, theme, syntax_highlighting, render_markdown, } } } #[derive(Debug, Clone)] enum MessageSegment { Text { lines: Vec, }, CodeBlock { language: Option, lines: Vec, }, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum FileOpenDisposition { Primary, SplitHorizontal, SplitVertical, Tab, } #[derive(Debug, Clone)] struct FileActionPrompt { kind: FileActionKind, buffer: String, } #[derive(Debug, Clone)] enum FileActionKind { CreateFile { base: PathBuf }, CreateFolder { base: PathBuf }, Rename { original: PathBuf }, Move { original: PathBuf }, Delete { target: PathBuf, confirm: String }, } impl FileActionPrompt { fn new(kind: FileActionKind, initial: impl Into) -> Self { Self { kind, buffer: initial.into(), } } fn push_char(&mut self, ch: char) { self.buffer.push(ch); } fn pop_char(&mut self) { self.buffer.pop(); } fn set_buffer(&mut self, buffer: impl Into) { self.buffer = buffer.into(); } fn is_destructive(&self) -> bool { matches!(self.kind, FileActionKind::Delete { .. }) } } impl ChatApp { pub async fn new( controller: SessionController, controller_event_rx: mpsc::UnboundedReceiver, ) -> 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 = Self::canonical_provider_id(&config_guard.general.default_provider); let show_onboarding = config_guard.ui.show_onboarding; let show_cursor_outside_insert = config_guard.ui.show_cursor_outside_insert; let syntax_highlighting = config_guard.ui.syntax_highlighting; let render_markdown = config_guard.ui.render_markdown; let show_timestamps = config_guard.ui.show_timestamps; let icon_mode = config_guard.ui.icon_mode; let keymap_path = config_guard.ui.keymap_path.clone(); let keymap_profile = config_guard.ui.keymap_profile.clone(); drop(config_guard); let keymap = { let registry = CommandRegistry::default(); Keymap::load(keymap_path.as_deref(), keymap_profile.as_deref(), ®istry) }; let current_keymap_profile = keymap.profile(); let theme = owlen_core::theme::get_theme(&theme_name).unwrap_or_else(|| { eprintln!("Warning: Theme '{}' not found, using default", theme_name); Theme::default() }); let workspace_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let file_tree = FileTreeState::new(workspace_root); let file_icons = FileIconResolver::from_mode(icon_mode); install_global_logger(); let mut app = Self { controller, mode: InputMode::Normal, mode_flash_until: None, status: if show_onboarding { ONBOARDING_STATUS_LINE.to_string() } else { "Normal mode • Press F1 for help".to_string() }, error: None, models: Vec::new(), annotated_models: Vec::new(), provider_scope_status: HashMap::new(), available_providers: Vec::new(), selected_provider: current_provider.clone(), selected_provider_index: 0, selected_model_item: None, model_selector_items: Vec::new(), model_filter_mode: FilterMode::All, model_filter_memory: FilterMode::All, model_search_query: String::new(), model_search_hits: HashMap::new(), provider_search_hits: HashMap::new(), visible_model_count: 0, model_info_panel: ModelInfoPanel::new(), model_details_cache: HashMap::new(), show_model_info: false, model_info_viewport_height: 0, expanded_provider: None, current_provider, message_line_cache: HashMap::new(), 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(), stream_tasks: HashMap::new(), textarea, mvu_model: AppModel::default(), keymap, current_keymap_profile, controller_event_rx, 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(), pending_file_action: None, command_palette: CommandPalette::new(), resource_catalog: Vec::new(), pending_resource_refs: Vec::new(), oauth_flows: HashMap::new(), repo_search: RepoSearchState::new(), repo_search_task: None, repo_search_rx: None, repo_search_file_map: HashMap::new(), symbol_search: SymbolSearchState::new(), symbol_search_task: None, symbol_search_rx: None, visual_start: None, visual_end: None, focused_panel: FocusedPanel::Input, chat_cursor: (0, 0), chat_line_offset: 0, thinking_cursor: (0, 0), code_workspace: CodeWorkspace::new(), pending_focus_chord: None, last_resize_tap: None, resize_snap_index: 0, last_snap_direction: None, last_ctrl_c: None, file_tree, file_icons, file_panel_collapsed: true, file_panel_width: 32, saved_sessions: Vec::new(), selected_session_index: 0, help_tab_index: 0, theme, available_themes: Vec::new(), selected_theme_index: 0, pending_consent: None, queued_consents: VecDeque::new(), system_status: if show_onboarding { ONBOARDING_SYSTEM_STATUS.to_string() } else { String::new() }, toasts: ToastManager::new(), debug_log: DebugLogState::new(), usage_snapshot: None, usage_thresholds: HashMap::new(), context_usage: None, last_layout: LayoutSnapshot::default(), _execution_budget: 50, agent_mode: false, agent_running: false, operating_mode: owlen_core::mode::Mode::default(), new_message_alert: false, show_cursor_outside_insert, syntax_highlighting, render_markdown, show_message_timestamps: show_timestamps, }; app.mvu_model.composer.mode = InputMode::Normal; app.mvu_model.composer.draft = app.controller.input_buffer().text().to_string(); app.append_system_status(&format!( "Icons: {} ({})", app.file_icons.status_label(), app.file_icons.detection_label() )); app.update_command_palette_catalog(); app.refresh_resource_catalog().await?; app.refresh_mcp_slash_commands().await?; if let Err(err) = app.restore_workspace_layout().await { eprintln!("Warning: failed to restore workspace layout: {err}"); } if show_onboarding { let mut cfg = app.controller.config_mut(); if cfg.ui.show_onboarding { cfg.ui.show_onboarding = false; if let Err(err) = config::save_config(&cfg) { eprintln!("Warning: Failed to persist onboarding preference: {err}"); } } } app.refresh_usage_summary().await?; 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() } fn enqueue_consent_request(&mut self, consent: ConsentDialogState) { if self.pending_consent.is_none() { self.pending_consent = Some(consent); } else { self.queued_consents.push_back(consent); } } fn advance_consent_queue(&mut self) { if self.pending_consent.is_some() { return; } if let Some(next) = self.queued_consents.pop_front() { self.pending_consent = Some(next); } } fn handle_controller_event(&mut self, event: ControllerEvent) -> Result<()> { match event { ControllerEvent::ToolRequested { request_id, message_id, tool_name, data_types, endpoints, tool_calls, } => { self.enqueue_consent_request(ConsentDialogState { request_id, message_id, tool_name, data_types, endpoints, tool_calls, }); } } Ok(()) } fn apply_tool_consent_resolution(&mut self, resolution: ToolConsentResolution) -> Result<()> { let ToolConsentResolution { message_id, tool_name, scope, tool_calls, .. } = resolution; match scope { ConsentScope::Denied => { self.pending_tool_execution = None; 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)); self.controller .conversation_mut() .push_assistant_message(format!( "I could not execute `{tool_name}` because consent was denied. \ Replying without running the tool." )); self.notify_new_activity(); } ConsentScope::Once | ConsentScope::Session | ConsentScope::Permanent => { let scope_label = match scope { ConsentScope::Once => "once", ConsentScope::Session => "session", ConsentScope::Permanent => "permanent", ConsentScope::Denied => unreachable!("handled above"), }; self.status = format!("✓ Consent granted ({scope_label}) for {}", tool_name); self.set_system_status(format!("✓ Consent granted ({scope_label}): {}", tool_name)); self.error = None; self.pending_tool_execution = Some((message_id, tool_calls)); } } self.pending_consent = None; self.advance_consent_queue(); Ok(()) } 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() } pub fn current_provider(&self) -> &str { &self.current_provider } pub fn usage_snapshot(&self) -> Option<&UsageSnapshot> { self.usage_snapshot.as_ref() } pub fn context_usage_with_fallback(&self) -> Option { if let Some(usage) = self.context_usage { Some(usage) } else { self.active_context_window().map(|window| ContextUsage { prompt_tokens: 0, completion_tokens: 0, context_window: window, }) } } fn update_context_usage(&mut self, usage: &TokenUsage) { let context_window = self .active_context_window() .unwrap_or(DEFAULT_CONTEXT_WINDOW_TOKENS); self.context_usage = Some(ContextUsage { prompt_tokens: usage.prompt_tokens, completion_tokens: usage.completion_tokens, context_window, }); } fn active_context_window(&self) -> Option { let current_model = self.controller.selected_model(); self.models.iter().find_map(|model| { if model.id == current_model || model.name == current_model { model.context_window } else { None } }) } pub fn should_show_code_view(&self) -> bool { if !matches!(self.operating_mode, owlen_core::mode::Mode::Code) { return false; } if let Some(pane) = self.code_workspace.active_pane() { return pane.display_path().is_some() || !pane.lines.is_empty(); } false } pub fn code_view_path(&self) -> Option<&str> { self.code_workspace .active_pane() .and_then(|pane| pane.display_path()) } pub fn is_code_mode(&self) -> bool { matches!(self.operating_mode, owlen_core::mode::Mode::Code) } pub fn code_view_lines(&self) -> &[String] { self.code_workspace .active_pane() .map(|pane| pane.lines.as_slice()) .unwrap_or(&[]) } pub fn code_view_scroll(&self) -> Option<&AutoScroll> { self.code_workspace.active_pane().map(|pane| &pane.scroll) } pub fn code_view_scroll_mut(&mut self) -> Option<&mut AutoScroll> { self.code_workspace .active_tab_mut() .and_then(|tab| tab.active_pane_mut()) .map(|pane| &mut pane.scroll) } pub fn set_code_view_viewport_height(&mut self, height: usize) { self.code_workspace.set_active_viewport_height(height); } pub fn repo_search(&self) -> &RepoSearchState { &self.repo_search } pub fn repo_search_mut(&mut self) -> &mut RepoSearchState { &mut self.repo_search } pub fn symbol_search(&self) -> &SymbolSearchState { &self.symbol_search } pub fn symbol_search_mut(&mut self) -> &mut SymbolSearchState { &mut self.symbol_search } fn repo_search_display_path(&self, absolute: &Path) -> String { if let Some(relative) = diff_paths(absolute, self.file_tree().root()) { if relative.as_os_str().is_empty() { ".".to_string() } else { relative.to_string_lossy().into_owned() } } else { absolute.to_string_lossy().into_owned() } } fn ensure_repo_search_file_index(&mut self, path: &Path) -> usize { if let Some(index) = self.repo_search_file_map.get(path).copied() { return index; } let display = self.repo_search_display_path(path); let idx = self .repo_search .ensure_file_entry(path.to_path_buf(), display); self.repo_search_file_map.insert(path.to_path_buf(), idx); idx } fn cancel_repo_search_process(&mut self) { if let Some(handle) = self.repo_search_task.take() { handle.abort(); } self.repo_search_rx = None; } fn poll_repo_search(&mut self) { if let Some(mut rx) = self.repo_search_rx.take() { use tokio::sync::mpsc::error::TryRecvError; let mut keep_receiver = true; loop { match rx.try_recv() { Ok(message) => { if !self.handle_repo_search_message(message) { keep_receiver = false; break; } } Err(TryRecvError::Empty) => break, Err(TryRecvError::Disconnected) => { self.repo_search_task = None; keep_receiver = false; break; } } } if keep_receiver { self.repo_search_rx = Some(rx); } } } fn handle_repo_search_message(&mut self, message: RepoSearchMessage) -> bool { match message { RepoSearchMessage::File { path } => { self.ensure_repo_search_file_index(&path); true } RepoSearchMessage::Match { path, line_number, column, preview, matched, } => { let idx = self.ensure_repo_search_file_index(&path); self.repo_search .add_match(idx, line_number, column, preview, matched); true } RepoSearchMessage::Done { matches } => { self.repo_search.finish(matches); self.repo_search_task = None; self.repo_search_rx = None; self.status = if matches == 0 { "Repo search: no matches".to_string() } else { format!("Repo search: {matches} match(es)") }; false } RepoSearchMessage::Error(err) => { self.repo_search.mark_error(err.clone()); self.repo_search_task = None; self.repo_search_rx = None; self.error = Some(err.clone()); self.status = format!("Repo search failed: {err}"); false } } } async fn start_repo_search(&mut self) -> Result<()> { let Some(query) = self.repo_search.prepare_run() else { if self.repo_search.query_input().is_empty() { self.status = "Enter a search query".to_string(); } return Ok(()); }; self.cancel_repo_search_process(); self.repo_search_file_map.clear(); let root = self.file_tree().root().to_path_buf(); match spawn_repo_search_task(root, query.clone()) { Ok((handle, rx)) => { self.repo_search_task = Some(handle); self.repo_search_rx = Some(rx); self.status = format!("Searching for \"{query}\"…"); self.error = None; } Err(err) => { let message = err.to_string(); self.repo_search.mark_error(message.clone()); self.error = Some(message.clone()); self.status = format!("Failed to start search: {message}"); } } Ok(()) } async fn open_repo_search_match(&mut self) -> Result<()> { let Some((file_index, match_index)) = self.repo_search.selected_indices() else { self.status = "Select a match to open".to_string(); return Ok(()); }; if !self.is_code_mode() { self.status = "Switch to code mode to open repository matches".to_string(); self.error = None; return Ok(()); } let (absolute, display, line_number, column) = { let file = &self.repo_search.files()[file_index]; let m = &file.matches[match_index]; ( file.absolute.clone(), file.display.clone(), m.line_number, m.column, ) }; let root = self.file_tree().root().to_path_buf(); let request_path = if absolute.starts_with(&root) { diff_paths(&absolute, &root) .filter(|rel| !rel.as_os_str().is_empty()) .map(|rel| rel.to_string_lossy().into_owned()) .unwrap_or_else(|| absolute.to_string_lossy().into_owned()) } else { absolute.to_string_lossy().into_owned() }; if !matches!(self.operating_mode, owlen_core::mode::Mode::Code) { self.set_mode(owlen_core::mode::Mode::Code).await; } match self.controller.read_file_with_tools(&request_path).await { Ok(content) => { self.prepare_code_view_target(FileOpenDisposition::Primary); self.set_code_view_content(display.clone(), Some(absolute.clone()), content); if let Some(pane) = self.code_workspace.active_pane_mut() { pane.scroll.stick_to_bottom = false; let target_line = line_number.saturating_sub(1) as usize; let viewport = pane.viewport_height.max(1); let scroll = target_line.saturating_sub(viewport / 2); pane.scroll.scroll = scroll; } self.file_tree_mut().reveal(&absolute); self.focused_panel = FocusedPanel::Code; self.ensure_focus_valid(); self.set_input_mode(InputMode::Normal); self.status = format!("Opened {}:{}:{column}", display, line_number); self.error = None; } Err(err) => { let message = format!("Failed to open {}: {}", display, err); self.error = Some(message.clone()); self.status = message; } } Ok(()) } async fn open_repo_search_scratch(&mut self) -> Result<()> { if !self.repo_search.has_results() { self.status = "No matches to open".to_string(); return Ok(()); } if !self.is_code_mode() { self.status = "Switch to code mode to open repository matches".to_string(); self.error = None; return Ok(()); } let mut buffer = String::new(); for file in self.repo_search.files() { if file.matches.is_empty() { continue; } buffer.push_str(&format!("{}\n", file.display)); for m in &file.matches { buffer.push_str(&format!( " {:>6}:{:<3} {}\n", m.line_number, m.column, m.preview )); } buffer.push('\n'); } let title = if let Some(query) = self.repo_search.last_query() { format!("Search results: {query}") } else { "Search results".to_string() }; if !matches!(self.operating_mode, owlen_core::mode::Mode::Code) { self.set_mode(owlen_core::mode::Mode::Code).await; } self.code_workspace.open_new_tab(); self.set_code_view_content(title.clone(), None::, buffer); if let Some(pane) = self.code_workspace.active_pane_mut() { pane.is_dirty = false; pane.is_staged = false; } self.focused_panel = FocusedPanel::Code; self.ensure_focus_valid(); self.set_input_mode(InputMode::Normal); self.status = format!("Opened scratch buffer for {title}"); Ok(()) } fn cancel_symbol_search_process(&mut self) { if let Some(handle) = self.symbol_search_task.take() { handle.abort(); } self.symbol_search_rx = None; } fn poll_symbol_search(&mut self) { if let Some(mut rx) = self.symbol_search_rx.take() { use tokio::sync::mpsc::error::TryRecvError; let mut keep_receiver = true; loop { match rx.try_recv() { Ok(message) => { if !self.handle_symbol_search_message(message) { keep_receiver = false; break; } } Err(TryRecvError::Empty) => break, Err(TryRecvError::Disconnected) => { self.symbol_search_task = None; keep_receiver = false; break; } } } if keep_receiver { self.symbol_search_rx = Some(rx); } } } fn handle_symbol_search_message(&mut self, message: SymbolSearchMessage) -> bool { match message { SymbolSearchMessage::Symbols(batch) => { self.symbol_search.add_symbols(batch); true } SymbolSearchMessage::Done => { self.symbol_search.finish(); self.symbol_search_task = None; self.symbol_search_rx = None; self.status = "Symbol index ready".to_string(); false } SymbolSearchMessage::Error(err) => { self.symbol_search.mark_error(err.clone()); self.symbol_search_task = None; self.symbol_search_rx = None; self.error = Some(err.clone()); self.status = format!("Symbol search failed: {err}"); false } } } async fn start_symbol_search(&mut self) -> Result<()> { self.cancel_symbol_search_process(); self.symbol_search.begin_index(); let root = self.file_tree().root().to_path_buf(); match spawn_symbol_search_task(root) { Ok((handle, rx)) => { self.symbol_search_task = Some(handle); self.symbol_search_rx = Some(rx); self.status = "Indexing symbols…".to_string(); self.error = None; } Err(err) => { let message = err.to_string(); self.symbol_search.mark_error(message.clone()); self.error = Some(message.clone()); self.status = format!("Unable to start symbol search: {message}"); } } Ok(()) } async fn open_symbol_search_entry(&mut self) -> Result<()> { let Some(entry) = self.symbol_search.selected_entry().cloned() else { self.status = "Select a symbol".to_string(); return Ok(()); }; let root = self.file_tree().root().to_path_buf(); let request_path = if entry.file.starts_with(&root) { diff_paths(&entry.file, &root) .filter(|rel| !rel.as_os_str().is_empty()) .map(|rel| rel.to_string_lossy().into_owned()) .unwrap_or_else(|| entry.file.to_string_lossy().into_owned()) } else { entry.file.to_string_lossy().into_owned() }; if !matches!(self.operating_mode, owlen_core::mode::Mode::Code) { self.set_mode(owlen_core::mode::Mode::Code).await; } match self.controller.read_file_with_tools(&request_path).await { Ok(content) => { self.prepare_code_view_target(FileOpenDisposition::Primary); self.set_code_view_content( entry.display_path.clone(), Some(entry.file.clone()), content, ); if let Some(pane) = self.code_workspace.active_pane_mut() { pane.scroll.stick_to_bottom = false; let target_line = entry.line.saturating_sub(1) as usize; let viewport = pane.viewport_height.max(1); let scroll = target_line.saturating_sub(viewport / 2); pane.scroll.scroll = scroll; } self.file_tree_mut().reveal(&entry.file); self.focused_panel = FocusedPanel::Code; self.ensure_focus_valid(); self.set_input_mode(InputMode::Normal); self.status = format!( "Jumped to {} {}:{}", entry.kind.label(), entry.display_path, entry.line ); self.error = None; } Err(err) => { let message = format!("Failed to open {}: {}", entry.display_path, err); self.error = Some(message.clone()); self.status = message; } } Ok(()) } pub fn code_view_viewport_height(&self) -> usize { self.code_workspace .active_pane() .map(|pane| pane.viewport_height) .unwrap_or(0) } pub fn has_loaded_code_view(&self) -> bool { self.code_workspace .active_pane() .map(|pane| pane.display_path().is_some() || !pane.lines.is_empty()) .unwrap_or(false) } pub fn file_tree(&self) -> &FileTreeState { &self.file_tree } pub fn file_tree_mut(&mut self) -> &mut FileTreeState { &mut self.file_tree } pub fn file_icons(&self) -> &FileIconResolver { &self.file_icons } pub fn workspace(&self) -> &CodeWorkspace { &self.code_workspace } pub fn workspace_mut(&mut self) -> &mut CodeWorkspace { &mut self.code_workspace } pub fn is_file_panel_collapsed(&self) -> bool { self.file_panel_collapsed } pub fn set_file_panel_collapsed(&mut self, collapsed: bool) { self.file_panel_collapsed = collapsed; } pub fn file_panel_width(&self) -> u16 { self.file_panel_width } pub fn set_file_panel_width(&mut self, width: u16) -> u16 { const MIN_WIDTH: u16 = 24; const MAX_WIDTH: u16 = 80; let clamped = width.clamp(MIN_WIDTH, MAX_WIDTH); self.file_panel_width = clamped; clamped } pub fn expand_file_panel(&mut self) { if !self.is_code_mode() { self.status = "Switch to code mode to use the file explorer".to_string(); self.error = None; return; } if self.file_panel_collapsed { self.file_panel_collapsed = false; self.focused_panel = FocusedPanel::Files; self.ensure_focus_valid(); } } pub fn collapse_file_panel(&mut self) { if !self.file_panel_collapsed { self.file_panel_collapsed = true; if matches!(self.focused_panel, FocusedPanel::Files) { self.focused_panel = FocusedPanel::Chat; } self.ensure_focus_valid(); } } pub fn toggle_file_panel(&mut self) { if !self.is_code_mode() { self.status = "File explorer is available in code mode".to_string(); self.error = None; return; } if self.file_panel_collapsed { self.expand_file_panel(); } else { self.collapse_file_panel(); } } // 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) { if let Err(err) = self.controller.set_operating_mode(mode).await { self.error = Some(format!("Failed to switch mode: {}", err)); return; } if !matches!(mode, owlen_core::mode::Mode::Code) { self.collapse_file_panel(); self.close_code_view(); self.set_system_status(String::new()); } self.operating_mode = mode; self.status = format!("Switched to {} mode", mode); self.error = None; } async fn set_web_tool_enabled(&mut self, enabled: bool) -> Result<()> { self.controller .set_tool_enabled("web_search", enabled) .await .map_err(|err| anyhow!(err))?; config::save_config(&self.controller.config())?; self.refresh_usage_summary().await?; Ok(()) } /// Override the status line with a custom message. pub fn set_status_message>(&mut self, status: S) { self.status = status.into(); } pub(crate) fn model_selector_items(&self) -> &[ModelSelectorItem] { &self.model_selector_items } pub(crate) fn annotated_models(&self) -> &[AnnotatedModelInfo] { &self.annotated_models } pub(crate) fn model_filter_mode(&self) -> FilterMode { self.model_filter_mode } pub(crate) fn model_search_query(&self) -> &str { &self.model_search_query } pub(crate) fn model_search_info(&self, index: usize) -> Option<&ModelSearchInfo> { self.model_search_hits.get(&index) } pub(crate) fn provider_search_highlight(&self, provider: &str) -> Option<&HighlightMask> { self.provider_search_hits.get(provider) } pub(crate) fn visible_model_count(&self) -> usize { self.visible_model_count } fn update_model_filter_mode(&mut self, mode: FilterMode) { if self.model_filter_mode != mode { self.model_filter_mode = mode; self.model_filter_memory = mode; self.rebuild_model_selector_items(); } else if !self.model_search_query.is_empty() { // Refresh search results against current filter self.rebuild_model_selector_items(); } } fn push_model_search_char(&mut self, ch: char) { if ch.is_control() { return; } self.model_search_query.push(ch); self.rebuild_model_selector_items(); self.update_model_search_status(); } fn pop_model_search_char(&mut self) { self.model_search_query.pop(); self.rebuild_model_selector_items(); self.update_model_search_status(); } fn clear_model_search_query(&mut self) { if self.model_search_query.is_empty() { return; } self.model_search_query.clear(); self.rebuild_model_selector_items(); self.update_model_search_status(); } fn reset_model_picker_state(&mut self) { if !self.model_search_query.is_empty() { self.model_search_query.clear(); } self.model_search_hits.clear(); self.provider_search_hits.clear(); self.visible_model_count = 0; } fn update_model_search_status(&mut self) { if !matches!( self.mode, InputMode::ModelSelection | InputMode::ProviderSelection ) { return; } if self.model_search_query.is_empty() { self.status = "Select a model to use".to_string(); } else { let count = self.visible_model_count(); if count == 1 { self.status = format!("Search \"{}\" → 1 match", self.model_search_query.trim()); } else { self.status = format!( "Search \"{}\" → {} matches", self.model_search_query.trim(), count ); } } } 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 cached_model_detail(&self, model_name: &str) -> Option<&DetailedModelInfo> { self.model_details_cache.get(model_name) } pub fn model_info_panel_mut(&mut self) -> &mut ModelInfoPanel { &mut self.model_info_panel } pub fn is_model_info_visible(&self) -> bool { self.show_model_info } pub fn set_model_info_visible(&mut self, visible: bool) { self.show_model_info = visible; if !visible { self.model_info_panel.reset_scroll(); self.model_info_viewport_height = 0; } } pub fn set_model_info_viewport_height(&mut self, height: usize) { self.model_info_viewport_height = height; } pub fn model_info_viewport_height(&self) -> usize { self.model_info_viewport_height } pub async fn ensure_model_details( &mut self, model_name: &str, force_refresh: bool, ) -> Result<()> { if !force_refresh && self.show_model_info && self .model_info_panel .current_model_name() .is_some_and(|name| name == model_name) { self.set_model_info_visible(false); self.status = "Closed model info panel".to_string(); self.error = None; return Ok(()); } if !force_refresh { if let Some(info) = self.model_details_cache.get(model_name).cloned() { self.model_info_panel.set_model_info(info); self.set_model_info_visible(true); self.status = format!("Showing model info for {}", model_name); self.error = None; return Ok(()); } } else { self.model_details_cache.remove(model_name); self.controller.invalidate_model_details(model_name).await; } match self .controller .model_details(model_name, force_refresh) .await { Ok(details) => { self.model_details_cache .insert(model_name.to_string(), details.clone()); self.model_info_panel.set_model_info(details); self.set_model_info_visible(true); self.status = if force_refresh { format!("Refreshed model info for {}", model_name) } else { format!("Showing model info for {}", model_name) }; self.error = None; Ok(()) } Err(err) => { self.error = Some(format!("Failed to load model info: {}", err)); Err(err.into()) } } } pub async fn prefetch_all_model_details(&mut self, force_refresh: bool) -> Result<()> { if force_refresh { self.controller.clear_model_details_cache().await; } match self.controller.all_model_details(force_refresh).await { Ok(details) => { if force_refresh { self.model_details_cache.clear(); } for info in details { self.model_details_cache.insert(info.name.clone(), info); } if let Some(current) = self .model_info_panel .current_model_name() .map(|s| s.to_string()) { if let Some(updated) = self.model_details_cache.get(¤t).cloned() { self.model_info_panel.set_model_info(updated); } } let total = self.model_details_cache.len(); self.status = format!("Cached model details for {} model(s)", total); self.error = None; Ok(()) } Err(err) => { self.error = Some(format!("Failed to prefetch model info: {}", err)); Err(err.into()) } } } 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 show_tutorial(&mut self) { self.error = None; self.status = TUTORIAL_STATUS.to_string(); self.system_status = TUTORIAL_SYSTEM_STATUS.to_string(); let tutorial_body = concat!( "Keybindings overview:\n", " • Movement: h/j/k/l, gg/G, w/b\n", " • Insert text: i or a (Esc to exit)\n", " • Visual select: v (Esc to exit)\n", " • Command mode: : (press Enter to run, Esc to cancel)\n", " • Send message: Enter in Insert mode\n", " • Help overlay: F1 or ?\n" ); self.controller .conversation_mut() .push_system_message(tutorial_body.to_string()); } pub fn command_buffer(&self) -> &str { self.command_palette.buffer() } pub fn command_suggestions(&self) -> &[PaletteSuggestion] { self.command_palette.suggestions() } fn set_input_mode(&mut self, mode: InputMode) { if self.mode != mode { self.mode_flash_until = Some(Instant::now() + Duration::from_millis(240)); } if !matches!( mode, InputMode::ModelSelection | InputMode::ProviderSelection ) && matches!( self.mode, InputMode::ModelSelection | InputMode::ProviderSelection ) { self.reset_model_picker_state(); } self.mode = mode; let _ = self.apply_app_event(AppEvent::Composer(ComposerEvent::ModeChanged { mode })); } pub fn mode_flash_active(&self) -> bool { self.mode_flash_until .map(|deadline| Instant::now() < deadline) .unwrap_or(false) } pub fn selected_suggestion(&self) -> usize { self.command_palette.selected_index() } /// Returns all available commands with their aliases /// Complete the current command with the selected suggestion fn complete_command(&mut self) { if let Some(suggestion) = self.command_palette.apply_selected() { self.status = format!(":{}", suggestion); } } 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(crate) fn set_layout_snapshot(&mut self, snapshot: LayoutSnapshot) { self.last_layout = snapshot; } fn region_for_position(&self, column: u16, row: u16) -> Option { self.last_layout.region_at(column, row) } pub fn current_keymap_profile(&self) -> KeymapProfile { self.current_keymap_profile } fn reload_keymap_from_config(&mut self) -> Result<()> { let registry = CommandRegistry::default(); let config = self.controller.config(); let keymap_path = config.ui.keymap_path.clone(); let keymap_profile = config.ui.keymap_profile.clone(); drop(config); self.keymap = Keymap::load(keymap_path.as_deref(), keymap_profile.as_deref(), ®istry); self.current_keymap_profile = self.keymap.profile(); Ok(()) } async fn switch_keymap_profile(&mut self, profile: KeymapProfile) -> Result<()> { if self.current_keymap_profile == profile { self.status = format!("Keymap already set to {}", profile.label()); self.error = None; return Ok(()); } { let mut cfg = self.controller.config_mut(); cfg.ui.keymap_profile = Some(profile.config_value().to_string()); cfg.ui.keymap_path = None; config::save_config(&cfg)?; } self.reload_keymap_from_config()?; self.status = format!("Keymap switched to {}", profile.label()); self.error = None; Ok(()) } pub fn is_debug_log_visible(&self) -> bool { self.debug_log.is_visible() } pub fn toggle_debug_log_panel(&mut self) { let now_visible = self.debug_log.toggle_visible(); if now_visible { self.status = "Debug log open — F12 to hide".to_string(); self.error = None; } else { self.status = "Debug log hidden".to_string(); self.error = None; } } pub fn debug_log_entries(&self) -> Vec { self.debug_log.entries() } pub fn toasts(&self) -> impl Iterator { self.toasts.iter() } pub fn push_toast(&mut self, level: ToastLevel, message: impl Into) { self.toasts.push(message, level); } fn prune_toasts(&mut self) { self.toasts.retain_active(); } fn poll_debug_log_updates(&mut self) { let new_entries = self.debug_log.take_unseen(); if new_entries.is_empty() { return; } let mut latest_summary: Option<(Level, String)> = None; for entry in new_entries.iter() { let toast_level = match entry.level { Level::Error => ToastLevel::Error, Level::Warn => ToastLevel::Warning, _ => continue, }; let summary = format!("{}: {}", entry.target, entry.message); let clipped = Self::ellipsize(&summary, 120); self.push_toast(toast_level, clipped.clone()); latest_summary = Some((entry.level, clipped)); } if !self.debug_log.is_visible() { if let Some((level, message)) = latest_summary { let level_label = match level { Level::Error => "Error", Level::Warn => "Warning", _ => "Log", }; self.status = format!("{level_label}: {message} (F12 to open debug log)"); self.error = None; } } } fn ellipsize(message: &str, max_len: usize) -> String { if message.chars().count() <= max_len { return message.to_string(); } let mut truncated = String::new(); for (idx, ch) in message.chars().enumerate() { if idx + 1 >= max_len { truncated.push('…'); break; } truncated.push(ch); } truncated } pub fn input_max_rows(&self) -> u16 { let config = self.controller.config(); config.ui.input_max_rows.max(1) } pub fn active_model_label(&self) -> String { let active_id = self.controller.selected_model(); if let Some(model) = self .models .iter() .find(|m| m.id == active_id || m.name == active_id) { Self::display_name_for_model(model) } else { active_id.to_string() } } pub fn is_loading(&self) -> bool { self.is_loading } pub fn is_streaming(&self) -> bool { !self.streaming.is_empty() } pub fn scrollback_limit(&self) -> usize { let limit = { let config = self.controller.config(); config.ui.scrollback_lines }; if limit == 0 { usize::MAX } else { limit } } pub fn has_new_message_alert(&self) -> bool { self.new_message_alert } pub fn clear_new_message_alert(&mut self) { self.new_message_alert = false; } fn notify_new_activity(&mut self) { if !self.auto_scroll.stick_to_bottom { self.new_message_alert = true; } } fn update_new_message_alert_after_scroll(&mut self) { if self.auto_scroll.stick_to_bottom { self.clear_new_message_alert(); } } fn model_palette_entries(&self) -> Vec { self.models .iter() .map(|model| ModelPaletteEntry { id: model.id.clone(), name: model.name.clone(), provider: model.provider.clone(), }) .collect() } fn update_command_palette_catalog(&mut self) { let providers = self.available_providers.clone(); let models = self.model_palette_entries(); self.command_palette .update_dynamic_sources(models, providers); } async fn refresh_resource_catalog(&mut self) -> Result<()> { let mut resources = self.controller.configured_resources().await; resources.sort_by(|a, b| a.server.cmp(&b.server).then(a.uri.cmp(&b.uri))); self.resource_catalog = resources; Ok(()) } async fn refresh_mcp_slash_commands(&mut self) -> Result<()> { let mut commands = Vec::new(); for (server, descriptor) in self.controller.list_mcp_tools().await { if !Self::tool_supports_slash(&descriptor) { continue; } let description = if descriptor.description.trim().is_empty() { None } else { Some(descriptor.description.clone()) }; commands.push(McpSlashCommand::new( server, descriptor.name.clone(), description, )); } slash::set_mcp_commands(commands); Ok(()) } fn tool_supports_slash(descriptor: &McpToolDescriptor) -> bool { if descriptor.name.trim().is_empty() { return false; } Self::tool_allows_empty_arguments(&descriptor.input_schema) } fn tool_allows_empty_arguments(schema: &Value) -> bool { match schema { Value::Object(map) => { if let Some(Value::Array(required)) = map.get("required") { !required .iter() .any(|entry| entry.as_str().is_some_and(|s| !s.is_empty())) } else { true } } _ => true, } } fn format_mcp_slash_message(server: &str, tool: &str, response: &McpToolResponse) -> String { let status = if response.success { "✓" } else { "✗" }; let payload = if response.success { Self::extract_mcp_primary_text(&response.output) } else { Self::extract_mcp_error(&response.output) .or_else(|| Self::extract_mcp_primary_text(&response.output)) } .unwrap_or_else(|| Self::pretty_print_value(&response.output)); if payload.trim().is_empty() { return format!("MCP {server}::{tool} {status}"); } if payload.contains('\n') { format!("MCP {server}::{tool} {status}\n```json\n{payload}\n```") } else { format!("MCP {server}::{tool} {status}\n{payload}") } } fn extract_mcp_primary_text(value: &Value) -> Option { if let Some(text) = value.as_str().filter(|text| !text.trim().is_empty()) { return Some(text.to_string()); } if let Value::Object(map) = value { const CANDIDATES: [&str; 6] = ["rendered", "text", "content", "value", "message", "body"]; for key in CANDIDATES { if let Some(Value::String(text)) = map.get(key) { if !text.trim().is_empty() { return Some(text.clone()); } } } if let Some(Value::Array(items)) = map.get("lines") { let mut collected = Vec::new(); for item in items { if let Some(segment) = item.as_str() { if !segment.trim().is_empty() { collected.push(segment.trim()); } } } if !collected.is_empty() { return Some(collected.join("\n")); } } } None } fn extract_mcp_error(value: &Value) -> Option { if let Value::Object(map) = value { if let Some(Value::String(message)) = map.get("error") { if !message.trim().is_empty() { return Some(message.clone()); } } } None } fn pretty_print_value(value: &Value) -> String { serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string()) } async fn resolve_pending_resource_references(&mut self) -> Result<()> { if self.pending_resource_refs.is_empty() { return Ok(()); } let mut resolved = 0usize; let references: Vec = self.pending_resource_refs.drain(..).collect(); for reference in references { match self.controller.resolve_resource_reference(&reference).await { Ok(Some(content)) => { let message = format!("Resource @{}:\n{}", reference, content); self.controller .conversation_mut() .push_system_message(message); resolved += 1; } Ok(None) => { self.push_toast( ToastLevel::Warning, format!( "Resource @{} is not defined in the current project.", reference ), ); } Err(err) => { self.push_toast( ToastLevel::Error, format!("Failed to load resource @{}: {}", reference, err), ); } } } if resolved > 0 { self.status = format!("Inserted {resolved} resource snippet(s)."); } Ok(()) } fn complete_resource_reference(&mut self) -> bool { if self.resource_catalog.is_empty() { return false; } let (row, col) = self.textarea.cursor(); let lines = self.textarea.lines().to_vec(); if row >= lines.len() { return false; } let line = &lines[row]; let chars: Vec = line.chars().collect(); if col > chars.len() { return false; } let mut start = col; while start > 0 { let ch = chars[start - 1]; if ch == '@' { start -= 1; break; } if ch.is_whitespace() { return false; } start -= 1; } if start >= col || chars.get(start) != Some(&'@') { return false; } if chars[start + 1..col].iter().any(|ch| ch.is_whitespace()) { return false; } let mut end = col; while end < chars.len() { let ch = chars[end]; if ch.is_whitespace() { break; } end += 1; } let typed_prefix: String = chars[start + 1..col].iter().collect(); let trailing_segment: String = chars[col..end].iter().collect(); let lower_prefix = typed_prefix.to_ascii_lowercase(); let lower_full = format!("{}{}", typed_prefix, trailing_segment).to_ascii_lowercase(); let mut matches: Vec<&McpResourceConfig> = self .resource_catalog .iter() .filter(|resource| { let reference = format!("{}:{}", resource.server, resource.uri); let lower_reference = reference.to_ascii_lowercase(); lower_reference.starts_with(&lower_full) || lower_reference.starts_with(&lower_prefix) || resource .title .as_ref() .map(|title| title.to_ascii_lowercase().starts_with(&lower_prefix)) .unwrap_or(false) }) .collect(); if matches.is_empty() { return false; } matches.sort_by(|a, b| a.server.cmp(&b.server).then(a.uri.cmp(&b.uri))); let (selected_server, selected_uri, selected_title) = { let selected = matches[0]; ( selected.server.clone(), selected.uri.clone(), selected.title.clone(), ) }; let replacement = format!("@{}:{}", selected_server, selected_uri); let mut new_line = String::new(); new_line.extend(chars[..start].iter()); new_line.push_str(&replacement); new_line.extend(chars[end..].iter()); let mut new_lines = lines; new_lines[row] = new_line; self.textarea = TextArea::new(new_lines); configure_textarea_defaults(&mut self.textarea); let new_col = start + replacement.len(); self.textarea .move_cursor(CursorMove::Jump(row as u16, new_col as u16)); self.sync_textarea_to_buffer(); if let Some(title) = selected_title.as_deref() { self.status = format!("Inserted resource {} ({title}).", replacement); } else { self.status = format!("Inserted resource {}.", replacement); } self.error = None; true } fn extract_resource_references(text: &str) -> Vec { let mut references = Vec::new(); let mut current = String::new(); let mut in_reference = false; for ch in text.chars() { if in_reference { if ch.is_whitespace() || matches!(ch, ',' | ';' | ')' | '(' | '.' | '!' | '?') { if current.contains(':') { references.push(current.clone()); } current.clear(); in_reference = false; } else { current.push(ch); } } else if ch == '@' { in_reference = true; current.clear(); } } if in_reference && current.contains(':') { references.push(current); } references } pub(crate) fn display_name_for_model(model: &ModelInfo) -> String { let base = { let trimmed = model.name.trim(); if trimmed.is_empty() { model.id.as_str() } else { trimmed } }; let scope = Self::model_scope_from_capabilities(model); let scope_suffix = match &scope { ModelScope::Local => "local".to_string(), ModelScope::Cloud => "cloud".to_string(), ModelScope::Other(other) => other.trim().to_ascii_lowercase(), }; if scope_suffix.is_empty() { base.to_string() } else { let lower = base.to_ascii_lowercase(); if lower.contains(&format!("· {}", scope_suffix)) { base.to_string() } else { format!("{base} · {scope_suffix}") } } } fn role_style(theme: &Theme, role: &Role) -> Style { match role { Role::User => Style::default().fg(theme.user_message_role), Role::Assistant => Style::default().fg(theme.assistant_message_role), Role::System => Style::default().fg(theme.mode_command), Role::Tool => Style::default().fg(theme.info), } } fn message_border_style(theme: &Theme, role: &Role) -> Style { let base_color = match role { Role::User => theme.user_message_role, Role::Assistant => theme.assistant_message_role, Role::System => theme.mode_command, Role::Tool => theme.info, }; let dimmed = Self::dim_color(base_color); Style::default().fg(dimmed).add_modifier(Modifier::DIM) } fn content_style(theme: &Theme, role: &Role) -> Style { if matches!(role, Role::Tool) { Style::default().fg(theme.tool_output) } else { Style::default() } } fn dim_color(color: Color) -> Color { match color { Color::Reset | Color::Indexed(_) => color, _ => { if let Some((r, g, b)) = Self::color_to_rgb(color) { let dim_component = |component: u8| -> u8 { let value = ((component as u16) * 2) / 5; value as u8 }; Color::Rgb(dim_component(r), dim_component(g), dim_component(b)) } else { color } } } } fn color_to_rgb(color: Color) -> Option<(u8, u8, u8)> { match color { Color::Black => Some((0, 0, 0)), Color::Red => Some((205, 0, 0)), Color::Green => Some((0, 205, 0)), Color::Yellow => Some((205, 205, 0)), Color::Blue => Some((0, 0, 205)), Color::Magenta => Some((205, 0, 205)), Color::Cyan => Some((0, 205, 205)), Color::Gray => Some((170, 170, 170)), Color::DarkGray => Some((85, 85, 85)), Color::LightRed => Some((255, 85, 85)), Color::LightGreen => Some((85, 255, 85)), Color::LightYellow => Some((255, 255, 85)), Color::LightBlue => Some((85, 85, 255)), Color::LightMagenta => Some((255, 85, 255)), Color::LightCyan => Some((85, 255, 255)), Color::White => Some((255, 255, 255)), Color::Rgb(r, g, b) => Some((r, g, b)), Color::Reset | Color::Indexed(_) => None, } } fn message_content_hash(role: &Role, content: &str, tool_signature: &str) -> u64 { let mut hasher = DefaultHasher::new(); role.to_string().hash(&mut hasher); content.hash(&mut hasher); tool_signature.hash(&mut hasher); hasher.finish() } fn invalidate_message_cache(&mut self, id: &Uuid) { self.message_line_cache.remove(id); } fn sync_ui_preferences_from_config(&mut self) { let (show_cursor, role_label_mode, syntax_highlighting, render_markdown, show_timestamps) = { let guard = self.controller.config(); ( guard.ui.show_cursor_outside_insert, guard.ui.role_label_mode, guard.ui.syntax_highlighting, guard.ui.render_markdown, guard.ui.show_timestamps, ) }; self.show_cursor_outside_insert = show_cursor; self.syntax_highlighting = syntax_highlighting; self.render_markdown = render_markdown; self.show_message_timestamps = show_timestamps; self.controller.set_role_label_mode(role_label_mode); self.message_line_cache.clear(); } pub fn cursor_should_be_visible(&self) -> bool { if matches!(self.mode, InputMode::Editing) { true } else { self.show_cursor_outside_insert } } pub fn should_highlight_code(&self) -> bool { true } pub fn render_markdown_enabled(&self) -> bool { self.render_markdown } pub fn set_render_markdown(&mut self, enabled: bool) { if self.render_markdown == enabled { self.status = if enabled { "Markdown rendering already enabled".to_string() } else { "Markdown rendering already disabled".to_string() }; self.error = None; return; } self.render_markdown = enabled; self.message_line_cache.clear(); { let mut guard = self.controller.config_mut(); guard.ui.render_markdown = enabled; } if let Err(err) = config::save_config(&self.controller.config()) { self.error = Some(format!("Failed to save config: {}", err)); } else { self.error = None; } self.status = if enabled { "Markdown rendering enabled".to_string() } else { "Markdown rendering disabled".to_string() }; } pub(crate) fn render_message_lines_cached( &mut self, message_index: usize, ctx: MessageRenderContext<'_>, ) -> Vec> { let MessageRenderContext { formatter, role_label_mode, body_width, card_width, is_streaming, loading_indicator, theme, syntax_highlighting, render_markdown, } = ctx; let (message_id, role, raw_content, timestamp, tool_calls, tool_result_id) = { let conversation = self.conversation(); let message = &conversation.messages[message_index]; ( message.id, message.role.clone(), message.content.clone(), message.timestamp, message.tool_calls.clone(), message .metadata .get("tool_call_id") .and_then(|value| value.as_str()) .map(|value| value.to_string()), ) }; let display_content = if matches!(role, Role::Assistant) { formatter.extract_thinking(&raw_content).0 } else if matches!(role, Role::Tool) { format_tool_output(&raw_content) } else { raw_content }; let normalized_content = display_content.replace("\r\n", "\n"); let trimmed = normalized_content.trim(); let content = trimmed.to_string(); let segments = parse_message_segments(trimmed, render_markdown); let tool_signature = tool_calls .as_ref() .map(|calls| { let mut names: Vec<&str> = calls.iter().map(|call| call.name.as_str()).collect(); names.sort_unstable(); names.join("|") }) .unwrap_or_default(); let content_hash = Self::message_content_hash(&role, &content, &tool_signature); if !is_streaming { if let Some(entry) = self.message_line_cache.get(&message_id) { if entry.wrap_width == card_width && entry.role_label_mode == role_label_mode && entry.syntax_highlighting == syntax_highlighting && entry.render_markdown == render_markdown && entry.theme_name == theme.name && entry.show_timestamps == self.show_message_timestamps && entry.metrics.body_width == body_width && entry.metrics.card_width == card_width && entry.content_hash == content_hash { return entry.lines.clone(); } } } let mut rendered: Vec> = Vec::new(); let content_style = Self::content_style(theme, &role); let mut indicator_target: Option = None; let indicator_span = if is_streaming { Some(Span::styled( format!(" {}", streaming_indicator_symbol(loading_indicator)), Style::default().fg(theme.cursor), )) } else { None }; let mut append_segments = |segments: &[MessageSegment], indent: &str, available_width: usize, indicator_target: &mut Option, code_width: usize| { if segments.is_empty() { let line_text = if indent.is_empty() { String::new() } else { indent.to_string() }; rendered.push(Line::from(vec![Span::styled(line_text, content_style)])); *indicator_target = Some(rendered.len() - 1); return; } for segment in segments { match segment { MessageSegment::Text { lines } => { if render_markdown { let block = lines.join("\n"); let markdown_lines = render_markdown_lines( &block, indent, available_width, content_style, ); for line in markdown_lines { rendered.push(line); *indicator_target = Some(rendered.len() - 1); } } else { for line_text in lines { let mut chunks = wrap_unicode(line_text.as_str(), available_width); if chunks.is_empty() { chunks.push(String::new()); } for chunk in chunks { let mut spans: Vec> = Vec::new(); if !indent.is_empty() { spans.push(Span::styled(indent.to_string(), content_style)); } let inline_spans = inline_code_spans_from_text(&chunk, theme, content_style); spans.extend(inline_spans); rendered.push(Line::from(spans)); *indicator_target = Some(rendered.len() - 1); } } } } MessageSegment::CodeBlock { language, lines } => { append_code_block_lines( &mut rendered, indent, code_width, language.as_deref(), lines, theme, syntax_highlighting, indicator_target, ); } } } }; match role_label_mode { RoleLabelDisplay::Above => { let indent = " "; let indent_width = UnicodeWidthStr::width(indent); let available_width = body_width.saturating_sub(indent_width).max(1); append_segments( &segments, indent, available_width, &mut indicator_target, body_width.saturating_sub(indent_width), ); } RoleLabelDisplay::Inline | RoleLabelDisplay::None => { let indent = ""; let available_width = body_width.max(1); append_segments( &segments, indent, available_width, &mut indicator_target, body_width, ); } } if let Some(indicator) = indicator_span { if let Some(idx) = indicator_target { if let Some(line) = rendered.get_mut(idx) { line.spans.push(indicator); } else { rendered.push(Line::from(vec![indicator])); } } else { rendered.push(Line::from(vec![indicator])); } } let markers = Self::message_tool_markers(&role, tool_calls.as_ref(), tool_result_id.as_deref()); let formatted_timestamp = if self.show_message_timestamps { Some(Self::format_message_timestamp(timestamp)) } else { None }; let card_lines = Self::wrap_message_in_card( rendered, &role, formatted_timestamp.as_deref(), &markers, card_width, theme, ); let metrics = MessageLayoutMetrics { line_count: card_lines.len(), body_width, card_width, }; debug_assert_eq!(metrics.line_count, card_lines.len()); if !is_streaming { self.message_line_cache.insert( message_id, MessageCacheEntry { theme_name: theme.name.clone(), wrap_width: card_width, role_label_mode, syntax_highlighting, render_markdown, show_timestamps: self.show_message_timestamps, content_hash, lines: card_lines.clone(), metrics: metrics.clone(), }, ); } card_lines } fn message_tool_markers( role: &Role, tool_calls: Option<&Vec>, tool_result_id: Option<&str>, ) -> Vec { let mut markers = Vec::new(); match role { Role::Assistant => { if let Some(calls) = tool_calls { const MAX_VISIBLE: usize = 3; for call in calls.iter().take(MAX_VISIBLE) { markers.push(format!("[Tool: {}]", call.name)); } if calls.len() > MAX_VISIBLE { markers.push(format!("[+{}]", calls.len() - MAX_VISIBLE)); } } } Role::Tool => { if let Some(id) = tool_result_id { markers.push(format!("[Result: {id}]")); } else { markers.push("[Result]".to_string()); } } _ => {} } markers } fn format_message_timestamp(timestamp: SystemTime) -> String { let datetime: DateTime = timestamp.into(); datetime.format("%H:%M").to_string() } fn wrap_message_in_card( mut lines: Vec>, role: &Role, timestamp: Option<&str>, markers: &[String], card_width: usize, theme: &Theme, ) -> Vec> { if card_width < MIN_MESSAGE_CARD_WIDTH { return Self::wrap_message_compact(lines, role, timestamp, markers, theme); } let inner_width = card_width.saturating_sub(4).max(1); let mut card_lines = Vec::with_capacity(lines.len() + 2); card_lines.push(Self::build_card_header( role, timestamp, markers, card_width, theme, )); if lines.is_empty() { lines.push(Line::from(String::new())); } for line in lines { card_lines.push(Self::wrap_card_body_line(line, inner_width, theme, role)); } card_lines.push(Self::build_card_footer(card_width, theme, role)); card_lines } fn wrap_message_compact( lines: Vec>, role: &Role, timestamp: Option<&str>, markers: &[String], theme: &Theme, ) -> Vec> { let role_style = Self::role_style(theme, role).add_modifier(Modifier::BOLD); let meta_style = Style::default().fg(theme.placeholder); let tool_style = Style::default() .fg(theme.tool_output) .add_modifier(Modifier::BOLD); let (emoji, title) = role_label_parts(role); let mut header_spans: Vec> = vec![Span::styled(format!("{emoji} {title}"), role_style)]; if let Some(ts) = timestamp { header_spans.push(Span::styled(" · ".to_string(), meta_style)); header_spans.push(Span::styled(ts.to_string(), meta_style)); } for marker in markers { header_spans.push(Span::styled(" ".to_string(), meta_style)); header_spans.push(Span::styled(marker.clone(), tool_style)); } let mut compact_lines = Vec::with_capacity(lines.len() + 2); compact_lines.push(Line::from(header_spans)); if lines.is_empty() { compact_lines.push(Line::from(vec![Span::raw("")])); } else { compact_lines.extend(lines); } compact_lines.push(Line::from(vec![Span::raw("")])); compact_lines } fn build_card_header( role: &Role, timestamp: Option<&str>, markers: &[String], card_width: usize, theme: &Theme, ) -> Line<'static> { let border_style = Self::message_border_style(theme, role); let role_style = Self::role_style(theme, role).add_modifier(Modifier::BOLD); let meta_style = Style::default().fg(theme.placeholder); let tool_style = Style::default() .fg(theme.tool_output) .add_modifier(Modifier::BOLD); let mut spans: Vec> = Vec::new(); spans.push(Span::styled("┌", border_style)); let mut consumed = 1usize; spans.push(Span::styled(" ", border_style)); consumed += 1; let (emoji, title) = role_label_parts(role); let label_text = format!("{emoji} {title}"); let label_width = UnicodeWidthStr::width(label_text.as_str()); spans.push(Span::styled(label_text, role_style)); consumed += label_width; if let Some(ts) = timestamp { let separator = " — "; let separator_width = UnicodeWidthStr::width(separator); let ts_width = UnicodeWidthStr::width(ts); if consumed + separator_width + ts_width + 1 < card_width { spans.push(Span::styled(separator.to_string(), border_style)); consumed += separator_width; spans.push(Span::styled(ts.to_string(), meta_style)); consumed += ts_width; } } for marker in markers { let spacer_width = 2usize; let marker_width = UnicodeWidthStr::width(marker.as_str()); if consumed + spacer_width + marker_width + 1 > card_width { break; } spans.push(Span::styled(" ".to_string(), border_style)); spans.push(Span::styled(marker.clone(), tool_style)); consumed += spacer_width + marker_width; } if consumed + 1 < card_width { spans.push(Span::styled(" ", border_style)); consumed += 1; } let remaining = card_width.saturating_sub(consumed + 1); if remaining > 0 { spans.push(Span::styled("─".repeat(remaining), border_style)); } spans.push(Span::styled("┐", border_style)); Line::from(spans) } fn build_card_footer(card_width: usize, theme: &Theme, role: &Role) -> Line<'static> { let border_style = Self::message_border_style(theme, role); let mut spans = Vec::new(); spans.push(Span::styled("└", border_style)); let horizontal = card_width.saturating_sub(2); if horizontal > 0 { spans.push(Span::styled("─".repeat(horizontal), border_style)); } spans.push(Span::styled("┘", border_style)); Line::from(spans) } fn wrap_card_body_line( line: Line<'static>, inner_width: usize, theme: &Theme, role: &Role, ) -> Line<'static> { let border_style = Self::message_border_style(theme, role); let mut spans = Vec::new(); spans.push(Span::styled("│ ", border_style)); let content_width = Self::line_display_width(&line).min(inner_width); let mut body_spans = line.spans; spans.append(&mut body_spans); if content_width < inner_width { spans.push(Span::styled( " ".repeat(inner_width - content_width), Style::default(), )); } spans.push(Span::styled(" │", border_style)); Line::from(spans) } fn build_card_header_plain( role: &Role, timestamp: Option<&str>, markers: &[String], card_width: usize, ) -> String { let mut result = String::new(); let mut consumed = 0usize; result.push('┌'); consumed += 1; result.push(' '); consumed += 1; let (emoji, title) = role_label_parts(role); let label_text = format!("{emoji} {title}"); result.push_str(&label_text); consumed += UnicodeWidthStr::width(label_text.as_str()); if let Some(ts) = timestamp { let separator = " — "; let separator_width = UnicodeWidthStr::width(separator); let ts_width = UnicodeWidthStr::width(ts); if consumed + separator_width + ts_width + 1 < card_width { result.push_str(separator); result.push_str(ts); consumed += separator_width + ts_width; } } for marker in markers { let spacer_width = 2usize; let marker_width = UnicodeWidthStr::width(marker.as_str()); if consumed + spacer_width + marker_width + 1 >= card_width { break; } result.push_str(" "); result.push_str(marker); consumed += spacer_width + marker_width; } let remaining = card_width.saturating_sub(consumed + 1); if remaining > 0 { result.push_str(&"─".repeat(remaining)); } result.push('┐'); result } fn wrap_card_body_line_plain(line: &str, inner_width: usize) -> String { let mut result = String::from("│ "); result.push_str(line); let content_width = UnicodeWidthStr::width(line); if content_width < inner_width { result.push_str(&" ".repeat(inner_width - content_width)); } result.push_str(" │"); result } fn build_card_footer_plain(card_width: usize) -> String { let mut result = String::new(); result.push('└'); let horizontal = card_width.saturating_sub(2); if horizontal > 0 { result.push_str(&"─".repeat(horizontal)); } result.push('┘'); result } fn line_display_width(line: &Line<'_>) -> usize { line.spans .iter() .map(|span| UnicodeWidthStr::width(span.content.as_ref())) .sum() } pub fn apply_chat_scrollback_trim(&mut self, removed: usize, remaining: usize) { if removed == 0 { self.chat_line_offset = 0; self.chat_cursor.0 = self.chat_cursor.0.min(remaining.saturating_sub(1)); return; } self.chat_line_offset = removed; self.auto_scroll.scroll = self.auto_scroll.scroll.saturating_sub(removed); self.auto_scroll.content_len = remaining; if let Some((row, _)) = &mut self.visual_start { if *row < removed { self.visual_start = None; } else { *row -= removed; } } if let Some((row, _)) = &mut self.visual_end { if *row < removed { self.visual_end = None; } else { *row -= removed; } } self.chat_cursor.0 = self.chat_cursor.0.saturating_sub(removed); if remaining == 0 { self.chat_cursor = (0, 0); } else if self.chat_cursor.0 >= remaining { self.chat_cursor.0 = remaining - 1; } let max_scroll = remaining.saturating_sub(self.viewport_height); if self.auto_scroll.scroll > max_scroll { self.auto_scroll.scroll = max_scroll; } if self.auto_scroll.stick_to_bottom { self.auto_scroll.on_viewport(self.viewport_height); } self.update_new_message_alert_after_scroll(); } pub fn set_theme(&mut self, theme: Theme) { self.theme = theme; self.message_line_cache.clear(); } pub fn switch_theme(&mut self, theme_name: &str) -> Result<()> { if let Some(theme) = owlen_core::theme::get_theme(theme_name) { self.theme = theme; self.message_line_cache.clear(); // 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)) } } fn focus_sequence(&self) -> Vec { let mut order = Vec::new(); if !self.file_panel_collapsed { order.push(FocusedPanel::Files); } order.push(FocusedPanel::Chat); if self.should_show_code_view() { order.push(FocusedPanel::Code); } if self.current_thinking.is_some() { order.push(FocusedPanel::Thinking); } order.push(FocusedPanel::Input); order } fn ensure_focus_valid(&mut self) { let order = self.focus_sequence(); if order.is_empty() { self.focused_panel = FocusedPanel::Chat; } else if !order.contains(&self.focused_panel) { self.focused_panel = order[0]; } if let FocusedPanel::Thinking = self.focused_panel { // Ensure the vertical split favours thinking panel if chat collapsed entirely if let Some(tab) = self.workspace_mut().active_tab_mut() { tab.root.ensure_ratio_bounds(); } } } pub fn cycle_focus_forward(&mut self) { let order = self.focus_sequence(); if order.is_empty() { self.focused_panel = FocusedPanel::Chat; return; } if !order.contains(&self.focused_panel) { self.focused_panel = order[0]; } let current_index = order .iter() .position(|panel| *panel == self.focused_panel) .unwrap_or(0); let next_index = (current_index + 1) % order.len(); self.focused_panel = order[next_index]; } pub fn cycle_focus_backward(&mut self) { let order = self.focus_sequence(); if order.is_empty() { self.focused_panel = FocusedPanel::Chat; return; } if !order.contains(&self.focused_panel) { self.focused_panel = order[0]; } let current_index = order .iter() .position(|panel| *panel == self.focused_panel) .unwrap_or(0); let prev_index = if current_index == 0 { order.len().saturating_sub(1) } else { current_index - 1 }; self.focused_panel = order[prev_index]; } pub fn focus_panel(&mut self, target: FocusedPanel) -> bool { match target { FocusedPanel::Files => { if self.file_panel_collapsed { self.expand_file_panel(); if self.file_panel_collapsed { return false; } } } FocusedPanel::Code => { if !self.should_show_code_view() { return false; } } FocusedPanel::Thinking => { if self.current_thinking.is_none() && self.agent_actions.is_none() { return false; } } FocusedPanel::Chat | FocusedPanel::Input => {} } let order = self.focus_sequence(); if !order.contains(&target) { return false; } self.focused_panel = target; self.ensure_focus_valid(); true } /// 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.clone()); let _ = self.apply_app_event(AppEvent::Composer(ComposerEvent::DraftChanged { content: 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); let _ = self.apply_app_event(AppEvent::Composer(ComposerEvent::DraftChanged { content: text, })); } fn apply_app_event(&mut self, event: AppEvent) -> Vec { mvu::update(&mut self.mvu_model, event) } async fn handle_app_effects(&mut self, effects: Vec) -> Result<()> { let mut pending = effects; while let Some(effect) = pending.pop() { match effect { AppEffect::SetStatus(message) => { self.error = Some(message.clone()); self.status = message; } AppEffect::RequestSubmit => { let outcome = self.process_composer_submission().await?; let mut follow_up = self.apply_app_event(AppEvent::Composer( ComposerEvent::SubmissionHandled { result: outcome }, )); pending.append(&mut follow_up); match outcome { SubmissionOutcome::MessageSent | SubmissionOutcome::CommandExecuted => { self.sync_buffer_to_textarea(); self.set_input_mode(InputMode::Normal); } SubmissionOutcome::Failed => { self.sync_buffer_to_textarea(); } } } AppEffect::ResolveToolConsent { request_id, scope } => { let resolution = self.controller.resolve_tool_consent(request_id, scope)?; self.apply_tool_consent_resolution(resolution)?; } } } Ok(()) } async fn process_composer_submission(&mut self) -> Result { match self.process_slash_submission().await? { SlashOutcome::NotCommand => { self.send_user_message_and_request_response(); Ok(SubmissionOutcome::MessageSent) } SlashOutcome::Consumed => Ok(SubmissionOutcome::CommandExecuted), SlashOutcome::Error => Ok(SubmissionOutcome::Failed), } } async fn try_execute_command(&mut self, key: &KeyEvent) -> Result { if let Some(command) = self.keymap.resolve(self.mode, key) { if self.execute_command(command).await? { return Ok(true); } } Ok(false) } async fn execute_command(&mut self, command: AppCommand) -> Result { match command { AppCommand::OpenModelPicker(filter) => { self.pending_key = None; if !matches!(self.mode, InputMode::Normal) { return Ok(false); } if let Err(err) = self.show_model_picker(filter).await { self.error = Some(err.to_string()); } Ok(true) } AppCommand::OpenCommandPalette | AppCommand::EnterCommandMode => { self.pending_key = None; if !matches!( self.mode, InputMode::Normal | InputMode::Editing | InputMode::Command ) { return Ok(false); } self.set_input_mode(InputMode::Command); self.command_palette.clear(); self.command_palette.ensure_suggestions(); self.status = ":".to_string(); self.error = None; Ok(true) } AppCommand::CycleFocusForward => { self.pending_key = None; if !matches!(self.mode, InputMode::Normal) { return Ok(false); } self.cycle_focus_forward(); self.status = format!("Focus: {}", Self::panel_label(self.focused_panel)); self.error = None; Ok(true) } AppCommand::CycleFocusBackward => { self.pending_key = None; if !matches!(self.mode, InputMode::Normal) { return Ok(false); } self.cycle_focus_backward(); self.status = format!("Focus: {}", Self::panel_label(self.focused_panel)); self.error = None; Ok(true) } AppCommand::FocusPanel(target) => { self.pending_key = None; if !matches!(self.mode, InputMode::Normal) { return Ok(false); } if self.focus_panel(target) { self.status = match target { FocusedPanel::Input => "Focus: Input — press i to edit".to_string(), _ => format!("Focus: {}", Self::panel_label(target)), }; self.error = None; } else { self.status = match target { FocusedPanel::Files => { if self.is_code_mode() { "Files panel is collapsed — use :files to reopen".to_string() } else { "Unable to focus Files panel".to_string() } } FocusedPanel::Code => "Open a file to focus the code workspace".to_string(), FocusedPanel::Thinking => "No reasoning panel to focus yet".to_string(), FocusedPanel::Chat => "Unable to focus Chat panel".to_string(), FocusedPanel::Input => "Unable to focus Input panel".to_string(), }; } Ok(true) } AppCommand::ComposerSubmit => { if !matches!(self.mode, InputMode::Editing) { return Ok(false); } self.pending_key = None; self.sync_textarea_to_buffer(); let effects = self.apply_app_event(AppEvent::Composer(ComposerEvent::Submit)); self.handle_app_effects(effects).await?; Ok(true) } AppCommand::ToggleDebugLog => { self.pending_key = None; self.toggle_debug_log_panel(); Ok(true) } AppCommand::SetKeymap(profile) => { self.pending_key = None; if profile.is_builtin() { self.switch_keymap_profile(profile).await?; } Ok(true) } } } fn panel_label(panel: FocusedPanel) -> &'static str { match panel { FocusedPanel::Files => "Files", FocusedPanel::Chat => "Chat", FocusedPanel::Thinking => "Thinking", FocusedPanel::Input => "Input", FocusedPanel::Code => "Code", } } pub fn adjust_vertical_split(&mut self, delta: f32) { if let Some(tab) = self.workspace_mut().active_tab_mut() { tab.root.nudge_ratio(delta); } } async fn process_slash_submission(&mut self) -> Result { let raw = self.controller.input_buffer().text().to_string(); if raw.trim().is_empty() { return Ok(SlashOutcome::NotCommand); } match slash::parse(&raw) { Ok(None) => Ok(SlashOutcome::NotCommand), Ok(Some(command)) => match self.execute_slash_command(command).await { Ok(()) => { self.input_buffer_mut().push_history_entry(raw.clone()); self.controller.input_buffer_mut().clear(); Ok(SlashOutcome::Consumed) } Err(err) => { self.error = Some(err.to_string()); self.status = "Slash command failed".to_string(); self.controller.input_buffer_mut().set_text(raw); Ok(SlashOutcome::Error) } }, Err(err) => { self.error = Some(err.to_string()); self.status = "Slash command error".to_string(); Ok(SlashOutcome::Error) } } } async fn execute_slash_command(&mut self, command: SlashCommand) -> Result<()> { match command { SlashCommand::Summarize { count } => { let prompt = if let Some(count) = count { format!( "Summarize the last {count} messages in this conversation. Highlight key decisions, open questions, and follow-up tasks." ) } else { "Summarize the conversation so far, calling out major decisions, blockers, and immediate next steps.".to_string() }; self.status = "Summarizing conversation...".to_string(); self.dispatch_user_prompt(prompt); } SlashCommand::Explain { snippet } => { let prompt = format!( "Explain the following code snippet. Cover what it does and call out any potential issues or improvements:\n```\n{}\n```", snippet ); self.status = "Explaining snippet...".to_string(); self.dispatch_user_prompt(prompt); } SlashCommand::Refactor { path } => { let trimmed = path.trim(); if trimmed.is_empty() { anyhow::bail!("usage: /refactor "); } let source = self.controller.read_file(trimmed).await?; let prompt = format!( "Refactor the file `{}`. Provide specific improvements for readability, safety, and maintainability. Include updated code where relevant.\n\n```text\n{}\n```", trimmed, source ); self.status = format!("Refactor review for {trimmed}..."); self.dispatch_user_prompt(prompt); } SlashCommand::TestPlan => { let prompt = "Generate a comprehensive test plan for this repository. Outline critical test suites, coverage gaps, and prioritized steps to reach confident automation.".to_string(); self.status = "Generating test plan...".to_string(); self.dispatch_user_prompt(prompt); } SlashCommand::Compact => { let prompt = "Compress our conversation history to its essentials. Summarize previous exchanges, preserve critical context, and indicate what state can be safely forgotten.".to_string(); self.status = "Compacting conversation...".to_string(); self.dispatch_user_prompt(prompt); } SlashCommand::McpTool { server, tool } => { self.status = format!("Running MCP tool {server}::{tool}..."); let response = self .controller .call_mcp_tool(&server, &tool, json!({})) .await .map_err(|err| { anyhow!("Failed to invoke MCP tool {}::{}: {}", server, tool, err) })?; let content = Self::format_mcp_slash_message(&server, &tool, &response); self.controller .conversation_mut() .push_system_message(content); self.auto_scroll.stick_to_bottom = true; self.new_message_alert = true; if response.success { self.status = format!("MCP {server}::{tool} result added to chat."); self.push_toast(ToastLevel::Info, format!("MCP {server}::{tool} completed.")); } else { self.status = format!("MCP {server}::{tool} reported an error (see chat)."); self.push_toast( ToastLevel::Warning, format!("MCP {server}::{tool} reported an error."), ); } self.error = None; } } Ok(()) } fn schedule_oauth_poll( &self, server: String, authorization: DeviceAuthorization, delay: Duration, ) { let sender = self.session_tx.clone(); tokio::spawn(async move { tokio::time::sleep(delay).await; let _ = sender.send(SessionEvent::OAuthPoll { server, authorization, }); }); } async fn start_oauth_login(&mut self, server: &str) -> Result<()> { if self.oauth_flows.contains_key(server) { self.error = Some(format!("OAuth flow for '{server}' is already in progress.")); return Ok(()); } let authorization = match self.controller.start_oauth_device_flow(server).await { Ok(auth) => auth, Err(err) => { self.error = Some(format!("Failed to start OAuth for '{server}': {err}")); return Ok(()); } }; self.oauth_flows .insert(server.to_string(), authorization.clone()); let link = authorization .verification_uri_complete .clone() .unwrap_or_else(|| authorization.verification_uri.clone()); let status = format!( "Authorize '{server}' via {} (code {}).", link, authorization.user_code ); self.status = status; self.error = None; let mut message = format!( "OAuth authorization required for `{server}`.\nVisit:\n{}\nEnter code: `{}`", link, authorization.user_code ); if let Some(hint) = &authorization.message && !hint.trim().is_empty() { message.push_str("\n\n"); message.push_str(hint); } if authorization.expires_at > Utc::now() { message.push_str(&format!( "\n\nThis code expires at {}.", authorization .expires_at .to_rfc3339_opts(chrono::SecondsFormat::Secs, true) )); } self.controller .conversation_mut() .push_system_message(message); self.auto_scroll.stick_to_bottom = true; self.notify_new_activity(); self.push_toast( ToastLevel::Warning, format!("Authorize {server}: code {}", authorization.user_code), ); let delay = authorization.interval; self.schedule_oauth_poll(server.to_string(), authorization.clone(), delay); Ok(()) } fn dispatch_user_prompt(&mut self, prompt: String) { if prompt.trim().is_empty() { self.error = Some("Slash command generated an empty request".to_string()); return; } self.controller.conversation_mut().push_user_message(prompt); self.auto_scroll.stick_to_bottom = true; self.pending_llm_request = true; self.set_system_status(String::new()); self.error = None; } fn set_code_view_content( &mut self, display_path: impl Into, absolute: Option, content: String, ) { let mut lines: Vec = content.lines().map(|line| line.to_string()).collect(); if content.ends_with('\n') { lines.push(String::new()); } let display = display_path.into(); self.code_workspace .set_active_contents(absolute, Some(display), lines); self.ensure_focus_valid(); } fn repo_layout_slug(&self) -> String { self.file_tree() .repo_name() .chars() .map(|ch| { if ch.is_ascii_alphanumeric() { ch.to_ascii_lowercase() } else { '-' } }) .collect() } fn workspace_layout_path(&self) -> Result { let base = data_local_dir().or_else(config_dir).ok_or_else(|| { anyhow!("Unable to determine configuration directory for layout persistence") })?; let mut dir = base.join("owlen").join("layouts"); fs::create_dir_all(&dir) .with_context(|| format!("Failed to create layout directory at {}", dir.display()))?; let mut hasher = DefaultHasher::new(); self.file_tree().root().to_string_lossy().hash(&mut hasher); let slug = self.repo_layout_slug(); dir.push(format!("{}-{}.toml", slug, hasher.finish())); Ok(dir) } fn persist_workspace_layout(&mut self) { if self.code_workspace.tabs().is_empty() { return; } let snapshot = self.code_workspace.snapshot(); match (self.workspace_layout_path(), toml::to_string(&snapshot)) { (Ok(path), Ok(serialized)) => { if let Err(err) = fs::write(&path, serialized) { eprintln!( "Warning: failed to write workspace layout {}: {}", path.display(), err ); } } (Err(err), _) => { eprintln!("Warning: unable to determine layout path: {err}"); } (_, Err(err)) => { eprintln!("Warning: failed to serialize workspace layout: {err}"); } } } fn restore_pane_from_request(&mut self, request: PaneRestoreRequest) -> Result<()> { let Some(absolute) = request.absolute_path.as_ref() else { return Ok(()); }; let content = fs::read_to_string(absolute) .with_context(|| format!("Failed to read restored file {}", absolute.display()))?; let mut lines: Vec = content.lines().map(|line| line.to_string()).collect(); if content.ends_with('\n') { lines.push(String::new()); } let display = request.display_path.clone().or_else(|| { diff_paths(absolute, self.file_tree().root()).map(|path| { if path.as_os_str().is_empty() { ".".to_string() } else { path.to_string_lossy().into_owned() } }) }); if self.code_workspace.set_pane_contents( request.pane_id, Some(absolute.clone()), display, lines, ) { self.code_workspace .restore_scroll(request.pane_id, &request.scroll); } Ok(()) } async fn restore_workspace_layout(&mut self) -> Result { let path = match self.workspace_layout_path() { Ok(path) => path, Err(_) => return Ok(false), }; if !path.exists() { return Ok(false); } let contents = fs::read_to_string(&path) .with_context(|| format!("Failed to read workspace layout {}", path.display()))?; let snapshot: WorkspaceSnapshot = toml::from_str(&contents) .with_context(|| format!("Failed to parse workspace layout {}", path.display()))?; let requests = self.code_workspace.apply_snapshot(snapshot); let mut restored_any = false; for request in requests { if let Err(err) = self.restore_pane_from_request(request) { eprintln!("Warning: failed to restore pane from layout: {err}"); } else { restored_any = true; } } if restored_any { self.focused_panel = FocusedPanel::Code; self.ensure_focus_valid(); self.status = "Workspace layout restored".to_string(); } Ok(restored_any) } fn direction_label(direction: PaneDirection) -> &'static str { match direction { PaneDirection::Left => "←", PaneDirection::Right => "→", PaneDirection::Up => "↑", PaneDirection::Down => "↓", } } fn handle_workspace_focus_move(&mut self, direction: PaneDirection) { self.pending_focus_chord = None; if self.code_workspace.move_focus(direction) { self.focused_panel = FocusedPanel::Code; self.ensure_focus_valid(); if let Some(share) = self.code_workspace.active_share() { self.status = format!( "Focused pane {} · {:.0}% share", Self::direction_label(direction), (share * 100.0).round() ); } else { self.status = format!("Focused pane {}", Self::direction_label(direction)); } self.error = None; self.persist_workspace_layout(); } else { self.status = "No pane in that direction".to_string(); } } fn handle_workspace_resize(&mut self, direction: PaneDirection) { self.pending_focus_chord = None; let now = Instant::now(); let is_double = self .last_resize_tap .map(|(prev_dir, instant)| { prev_dir == direction && now.duration_since(instant) <= RESIZE_DOUBLE_TAP_WINDOW }) .unwrap_or(false); let share_opt = if is_double { if self.last_snap_direction != Some(direction) { self.resize_snap_index = 0; } let snap = RESIZE_SNAP_VALUES[self.resize_snap_index % RESIZE_SNAP_VALUES.len()]; let result = self.code_workspace.snap_active_share(direction, snap); if result.is_some() { self.last_snap_direction = Some(direction); self.resize_snap_index = (self.resize_snap_index + 1) % RESIZE_SNAP_VALUES.len(); } result } else { self.last_snap_direction = None; self.resize_snap_index = 0; self.code_workspace .resize_active_step(direction, RESIZE_STEP) }; match share_opt { Some(share) => { if is_double { self.status = format!( "Pane snapped {} · {:.0}% share", Self::direction_label(direction), (share * 100.0).round() ); } else { self.status = format!( "Pane resized {} · {:.0}% share", Self::direction_label(direction), (share * 100.0).round() ); } self.focused_panel = FocusedPanel::Code; self.ensure_focus_valid(); self.error = None; self.persist_workspace_layout(); if is_double { self.last_resize_tap = None; } else { self.last_resize_tap = Some((direction, now)); } } None => { self.status = "No adjacent split to resize".to_string(); self.last_resize_tap = Some((direction, now)); self.last_snap_direction = None; } } } fn prepare_code_view_target(&mut self, disposition: FileOpenDisposition) -> bool { match disposition { FileOpenDisposition::Primary => true, FileOpenDisposition::SplitHorizontal => self .code_workspace .split_active(SplitAxis::Horizontal) .is_some(), FileOpenDisposition::SplitVertical => self .code_workspace .split_active(SplitAxis::Vertical) .is_some(), FileOpenDisposition::Tab => { self.code_workspace.open_new_tab(); true } } } fn display_label_for_absolute(&self, absolute: &Path) -> String { let root = self.file_tree().root(); if let Some(relative) = diff_paths(absolute, root) { let rel_str = relative.to_string_lossy().into_owned(); if rel_str.is_empty() { ".".to_string() } else { rel_str } } else { absolute.to_string_lossy().into_owned() } } fn buffer_label(&self, display: Option<&str>, absolute: Option<&Path>) -> String { if let Some(display) = display { let trimmed = display.trim(); if trimmed.is_empty() { "untitled buffer".to_string() } else { trimmed.to_string() } } else if let Some(absolute) = absolute { self.display_label_for_absolute(absolute) } else { "untitled buffer".to_string() } } async fn save_active_code_buffer( &mut self, path_arg: Option, force: bool, ) -> Result { let pane_snapshot = if let Some(pane) = self.code_workspace.active_pane() { ( pane.lines.join("\n"), pane.absolute_path().map(Path::to_path_buf), pane.display_path().map(|s| s.to_string()), pane.is_dirty, ) } else { self.status = "No active file to save".to_string(); self.error = Some("Open a file before saving".to_string()); return Ok(SaveStatus::Failed); }; let (content, existing_absolute, existing_display, was_dirty) = pane_snapshot; if !was_dirty && path_arg.is_none() && !force { let label = self.buffer_label(existing_display.as_deref(), existing_absolute.as_deref()); self.status = format!("No changes to write ({label})"); self.error = None; return Ok(SaveStatus::NoChanges); } let (request_path, target_absolute, target_display) = if let Some(path_arg) = path_arg { let trimmed = path_arg.trim(); if trimmed.is_empty() { self.status = "Save aborted: empty path".to_string(); self.error = Some("Provide a path to save this buffer".to_string()); return Ok(SaveStatus::Failed); } let provided_path = PathBuf::from(trimmed); let absolute = self.absolute_tree_path(&provided_path); let request = if provided_path.is_absolute() { provided_path.to_string_lossy().into_owned() } else { trimmed.to_string() }; let display = self.display_label_for_absolute(&absolute); (request, absolute, display) } else if let Some(display) = existing_display.clone() { let path = PathBuf::from(&display); let absolute = if path.is_absolute() { path.clone() } else { self.absolute_tree_path(&path) }; let display_label = self.display_label_for_absolute(&absolute); (display, absolute, display_label) } else if let Some(absolute) = existing_absolute.clone() { let request = absolute.to_string_lossy().into_owned(); let display = self.display_label_for_absolute(&absolute); (request, absolute, display) } else { self.status = "No path associated with buffer".to_string(); self.error = Some("Use :w to save this buffer".to_string()); return Ok(SaveStatus::Failed); }; match self.controller.write_file(&request_path, &content).await { Ok(()) => { if let Some(tab) = self.code_workspace.active_tab_mut() { if let Some(pane) = tab.active_pane_mut() { pane.update_paths( Some(target_absolute.clone()), Some(target_display.clone()), ); pane.is_dirty = false; pane.is_staged = false; } tab.update_title_from_active(); } match self.file_tree_mut().refresh() { Ok(()) => { self.file_tree_mut().reveal(&target_absolute); self.ensure_focus_valid(); } Err(err) => { self.error = Some(format!( "Saved {} but failed to refresh tree: {}", target_display, err )); } } self.status = format!("Wrote {}", target_display); if self.error.is_none() { self.set_system_status(format!("Saved {}", target_display)); } Ok(SaveStatus::Saved) } Err(err) => { self.error = Some(format!("Failed to save {}: {}", target_display, err)); self.status = format!("Failed to save {}", target_display); Ok(SaveStatus::Failed) } } } fn close_active_code_buffer(&mut self, force: bool) -> bool { let snapshot = if let Some(pane) = self.code_workspace.active_pane() { ( pane.display_path().map(|s| s.to_string()), pane.absolute_path().map(Path::to_path_buf), pane.is_dirty, ) } else { self.status = "No active file to close".to_string(); self.error = Some("Open a file before closing it".to_string()); return false; }; let (display_path, absolute_path, is_dirty) = snapshot; if is_dirty && !force { let label = self.buffer_label(display_path.as_deref(), absolute_path.as_deref()); self.status = format!("Unsaved changes in {label} — use :w to save or :q! to discard"); self.error = Some(format!("Unsaved changes detected in {}", label)); return false; } let label = self.buffer_label(display_path.as_deref(), absolute_path.as_deref()); self.close_code_view(); self.status = format!("Closed {}", label); self.error = None; self.set_system_status(String::new()); true } fn split_active_pane(&mut self, axis: SplitAxis) { let Some(snapshot) = self.code_workspace.active_pane().cloned() else { self.status = "No pane to split".to_string(); return; }; if self.code_workspace.split_active(axis).is_some() { let lines = snapshot.lines.clone(); let absolute = snapshot.absolute_path.clone(); let display = snapshot.display_path.clone(); self.code_workspace .set_active_contents(absolute, display, lines); if let Some(pane) = self.code_workspace.active_pane_mut() { pane.is_dirty = snapshot.is_dirty; pane.is_staged = snapshot.is_staged; pane.viewport_height = snapshot.viewport_height; pane.scroll = snapshot.scroll.clone(); } self.focused_panel = FocusedPanel::Code; self.ensure_focus_valid(); self.status = match axis { SplitAxis::Horizontal => "Split pane horizontally".to_string(), SplitAxis::Vertical => "Split pane vertically".to_string(), }; self.error = None; self.persist_workspace_layout(); } else { self.status = "Unable to split pane".to_string(); self.error = Some("Unable to split pane".to_string()); } } fn close_code_view(&mut self) { self.code_workspace.clear_active_pane(); if matches!(self.focused_panel, FocusedPanel::Code) { self.focused_panel = FocusedPanel::Chat; } self.ensure_focus_valid(); self.persist_workspace_layout(); } fn absolute_tree_path(&self, path: &Path) -> PathBuf { if path.as_os_str().is_empty() { self.file_tree().root().to_path_buf() } else if path.is_absolute() { path.to_path_buf() } else { self.file_tree().root().join(path) } } fn relative_tree_display(&self, path: &Path) -> String { if path.as_os_str().is_empty() { ".".to_string() } else { path.to_string_lossy().into_owned() } } async fn open_selected_file_from_tree( &mut self, disposition: FileOpenDisposition, ) -> Result<()> { if !self.is_code_mode() { self.status = "Switch to code mode to open files".to_string(); self.error = None; return Ok(()); } let selected_opt = { let tree = self.file_tree(); tree.selected_node().cloned() }; let Some(selected) = selected_opt else { self.status = "No file selected".to_string(); return Ok(()); }; if selected.is_dir { let was_expanded = selected.is_expanded; self.file_tree_mut().toggle_expand(); let label = self.relative_tree_display(&selected.path); self.status = if was_expanded { format!("Collapsed {}", label) } else { format!("Expanded {}", label) }; return Ok(()); } if selected.path.as_os_str().is_empty() { return Ok(()); } let relative_display = self.relative_tree_display(&selected.path); let absolute_path = self.absolute_tree_path(&selected.path); let request_path = if selected.path.is_absolute() { selected.path.to_string_lossy().into_owned() } else { relative_display.clone() }; match self.controller.read_file_with_tools(&request_path).await { Ok(content) => { let prepared = self.prepare_code_view_target(disposition); self.set_code_view_content( relative_display.clone(), Some(absolute_path.clone()), content, ); self.focused_panel = FocusedPanel::Code; self.ensure_focus_valid(); self.file_tree_mut().reveal(&absolute_path); if !prepared { self.error = Some("Unable to create requested split; opened in active pane".to_string()); } else { self.error = None; } self.status = match (disposition, prepared) { (FileOpenDisposition::Primary, _) => format!("Opened {}", relative_display), (FileOpenDisposition::SplitHorizontal, true) => { format!("Opened {} in horizontal split", relative_display) } (FileOpenDisposition::SplitVertical, true) => { format!("Opened {} in vertical split", relative_display) } (FileOpenDisposition::Tab, true) => { format!("Opened {} in new tab", relative_display) } (FileOpenDisposition::SplitHorizontal, false) | (FileOpenDisposition::SplitVertical, false) => { format!("Opened {} (split unavailable)", relative_display) } (FileOpenDisposition::Tab, false) => { format!("Opened {} (tab unavailable)", relative_display) } }; self.set_system_status(format!("Viewing {}", relative_display)); self.persist_workspace_layout(); } Err(err) => { self.error = Some(format!("Failed to open {}: {}", relative_display, err)); } } Ok(()) } fn copy_selected_path(&mut self, relative: bool) { let selected_opt = { let tree = self.file_tree(); tree.selected_node().cloned() }; let Some(selected) = selected_opt else { self.status = "No file selected".to_string(); return; }; let path_string = if relative { self.relative_tree_display(&selected.path) } else { let abs = self.absolute_tree_path(&selected.path); abs.to_string_lossy().into_owned() }; self.clipboard = path_string.clone(); self.status = if relative { format!("Copied relative path: {}", path_string) } else { format!("Copied path: {}", path_string) }; self.error = None; } fn selected_file_node(&self) -> Option { let tree = self.file_tree(); tree.selected_node().cloned() } fn mutate_file_filter(&mut self, mutate: F) where F: FnOnce(&mut String), { let mut query = { let tree = self.file_tree(); tree.filter_query().to_string() }; mutate(&mut query); let query_is_empty = query.is_empty(); { let tree = self.file_tree_mut(); if query_is_empty { tree.set_filter_mode(FileFilterMode::Glob); } tree.set_filter_query(query.clone()); } if query_is_empty { self.status = "Filter cleared".to_string(); } else { let mode = match self.file_tree().filter_mode() { FileFilterMode::Glob => "glob", FileFilterMode::Fuzzy => "fuzzy", }; self.status = format!("Filter ({mode}): {}", query); } self.error = None; } fn backspace_file_filter(&mut self) { self.mutate_file_filter(|query| { query.pop(); }); } fn clear_file_filter(&mut self) { self.mutate_file_filter(|query| { query.clear(); }); } fn append_file_filter_char(&mut self, ch: char) { self.mutate_file_filter(|query| { query.push(ch); }); } fn toggle_hidden_files(&mut self) { match self.file_tree_mut().toggle_hidden() { Ok(()) => { let show_hidden = self.file_tree().show_hidden(); self.status = if show_hidden { "Hidden files visible".to_string() } else { "Hidden files hidden".to_string() }; self.error = None; } Err(err) => { self.error = Some(format!("Failed to toggle hidden files: {}", err)); } } } fn create_file_from_command(&mut self, path: &str) -> Result { if !self.is_code_mode() { return Err(anyhow!("File creation is only available in code mode")); } let trimmed = path.trim(); if trimmed.is_empty() { return Err(anyhow!("File path cannot be empty")); } let relative = PathBuf::from(trimmed); validate_relative_path(&relative, true)?; let file_name = relative .file_name() .ok_or_else(|| anyhow!("File path must include a file name"))? .to_string_lossy() .into_owned(); let base = relative .parent() .filter(|parent| !parent.as_os_str().is_empty()) .map(|parent| parent.to_path_buf()) .unwrap_or_else(PathBuf::new); let prompt = FileActionPrompt::new(FileActionKind::CreateFile { base }, file_name); let message = self.perform_file_action(prompt)?; self.expand_file_panel(); Ok(message) } pub fn file_panel_prompt_text(&self) -> Option<(String, bool)> { self.pending_file_action.as_ref().map(|prompt| { ( self.describe_file_action_prompt(prompt), prompt.is_destructive(), ) }) } fn describe_file_action_prompt(&self, prompt: &FileActionPrompt) -> String { let buffer_display = if prompt.buffer.trim().is_empty() { "".to_string() } else { prompt.buffer.clone() }; let base_message = match &prompt.kind { FileActionKind::CreateFile { base } => { let base_display = self.relative_tree_display(base); format!("Create file in {} ▸ {}", base_display, buffer_display) } FileActionKind::CreateFolder { base } => { let base_display = self.relative_tree_display(base); format!("Create folder in {} ▸ {}", base_display, buffer_display) } FileActionKind::Rename { original } => { let current_display = self.relative_tree_display(original); format!("Rename {} → {}", current_display, buffer_display) } FileActionKind::Move { original } => { let current_display = self.relative_tree_display(original); format!("Move {} → {}", current_display, buffer_display) } FileActionKind::Delete { target, .. } => { let target_display = self.relative_tree_display(target); format!( "Delete {} — type filename to confirm ▸ {}", target_display, buffer_display ) } }; format!("{base_message} (Enter to apply · Esc to cancel)") } fn begin_file_action(&mut self, kind: FileActionKind, initial: impl Into) { if !self.is_code_mode() { self.status = "Switch to code mode to manage files".to_string(); self.error = None; return; } let prompt = FileActionPrompt::new(kind, initial); self.status = self.describe_file_action_prompt(&prompt); self.error = None; self.pending_file_action = Some(prompt); } fn refresh_file_action_status(&mut self) { if let Some(prompt) = self.pending_file_action.as_ref() { self.status = self.describe_file_action_prompt(prompt); } } fn cancel_file_action(&mut self) { self.pending_file_action = None; self.status = "File action cancelled".to_string(); self.error = None; } fn handle_file_action_prompt(&mut self, key: &crossterm::event::KeyEvent) -> Result { use crossterm::event::{KeyCode, KeyModifiers}; if self.pending_file_action.is_none() { return Ok(false); } let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); let alt = key.modifiers.contains(KeyModifiers::ALT); match key.code { KeyCode::Enter if !ctrl && !alt => { self.apply_pending_file_action()?; return Ok(true); } KeyCode::Esc if !ctrl && !alt => { self.cancel_file_action(); return Ok(true); } KeyCode::Backspace if !ctrl && !alt => { if let Some(prompt) = self.pending_file_action.as_mut() { prompt.pop_char(); } self.refresh_file_action_status(); self.error = None; return Ok(true); } KeyCode::Char(c) if !ctrl && !alt => { if let Some(prompt) = self.pending_file_action.as_mut() { prompt.push_char(c); } self.refresh_file_action_status(); self.error = None; return Ok(true); } KeyCode::Tab if !ctrl && !alt => { if let Some(prompt) = self.pending_file_action.as_mut() { prompt.push_char('\t'); } self.refresh_file_action_status(); self.error = None; return Ok(true); } KeyCode::Delete if !ctrl && !alt => { if let Some(prompt) = self.pending_file_action.as_mut() { prompt.set_buffer(String::new()); } self.refresh_file_action_status(); self.error = None; return Ok(true); } _ => {} } Ok(false) } fn apply_pending_file_action(&mut self) -> Result<()> { let Some(prompt) = self.pending_file_action.take() else { return Ok(()); }; let cloned_prompt = prompt.clone(); match self.perform_file_action(prompt) { Ok(message) => { self.status = message; self.error = None; Ok(()) } Err(err) => { self.pending_file_action = Some(cloned_prompt); self.error = Some(err.to_string()); Err(err) } } } async fn launch_external_editor(&mut self) -> Result<()> { if !self.is_code_mode() { self.status = "Switch to code mode to launch the external editor".to_string(); self.error = None; return Ok(()); } let Some(selected) = self.selected_file_node() else { self.status = "No file selected".to_string(); return Ok(()); }; let relative = selected.path.clone(); let absolute = self.absolute_tree_path(&relative); let editor = env::var("EDITOR") .or_else(|_| env::var("VISUAL")) .unwrap_or_else(|_| "vi".to_string()); self.status = format!("Launching {} {}", editor, absolute.display()); self.error = None; let editor_cmd = editor.clone(); let path_arg = absolute.clone(); let raw_mode_disabled = disable_raw_mode().is_ok(); let join_result = task::spawn_blocking(move || Command::new(&editor_cmd).arg(&path_arg).status()).await; if raw_mode_disabled { let _ = enable_raw_mode(); } let join_result = join_result.context("Editor task failed to join")?; match join_result { Ok(status) => { if status.success() { self.status = format!( "Closed {} for {}", editor, self.relative_tree_display(&relative) ); self.error = None; } else { let code = status .code() .map(|c| c.to_string()) .unwrap_or_else(|| "signal".to_string()); self.error = Some(format!("{} exited with status {}", editor, code)); } } Err(err) => { self.error = Some(format!("Failed to launch {}: {}", editor, err)); } } match self.file_tree_mut().refresh() { Ok(()) => { self.file_tree_mut().reveal(&absolute); self.ensure_focus_valid(); } Err(err) => { self.error = Some(format!("Failed to refresh file tree: {}", err)); } } Ok(()) } fn perform_file_action(&mut self, prompt: FileActionPrompt) -> Result { if !self.is_code_mode() { return Err(anyhow!("File actions are only available in code mode")); } match prompt.kind { FileActionKind::CreateFile { base } => { let name = prompt.buffer.trim(); if name.is_empty() { return Err(anyhow!("File name cannot be empty")); } let name_path = PathBuf::from(name); validate_relative_path(&name_path, true)?; let relative = if base.as_os_str().is_empty() { name_path } else { base.join(name_path) }; let absolute = self.absolute_tree_path(&relative); if absolute.exists() { return Err(anyhow!("{} already exists", absolute.display())); } if let Some(parent) = absolute.parent() { fs::create_dir_all(parent).with_context(|| { format!( "Failed to create parent directories for {}", absolute.display() ) })?; } OpenOptions::new() .create_new(true) .write(true) .open(&absolute) .with_context(|| format!("Failed to create {}", absolute.display()))?; self.file_tree_mut() .refresh() .context("Failed to refresh file tree")?; self.file_tree_mut().reveal(&absolute); self.ensure_focus_valid(); Ok(format!( "Created file {}", self.relative_tree_display(&relative) )) } FileActionKind::CreateFolder { base } => { let name = prompt.buffer.trim(); if name.is_empty() { return Err(anyhow!("Folder name cannot be empty")); } let name_path = PathBuf::from(name); validate_relative_path(&name_path, true)?; let relative = if base.as_os_str().is_empty() { name_path } else { base.join(name_path) }; let absolute = self.absolute_tree_path(&relative); if absolute.exists() { return Err(anyhow!("{} already exists", absolute.display())); } fs::create_dir_all(&absolute) .with_context(|| format!("Failed to create {}", absolute.display()))?; self.file_tree_mut() .refresh() .context("Failed to refresh file tree")?; self.file_tree_mut().reveal(&absolute); self.ensure_focus_valid(); Ok(format!( "Created folder {}", self.relative_tree_display(&relative) )) } FileActionKind::Rename { original } => { if original.as_os_str().is_empty() { return Err(anyhow!("Cannot rename workspace root")); } let name = prompt.buffer.trim(); if name.is_empty() { return Err(anyhow!("New name cannot be empty")); } validate_relative_path(Path::new(name), false)?; let new_relative = original .parent() .map(|parent| { if parent.as_os_str().is_empty() { PathBuf::from(name) } else { parent.join(name) } }) .unwrap_or_else(|| PathBuf::from(name)); let source_abs = self.absolute_tree_path(&original); let target_abs = self.absolute_tree_path(&new_relative); if target_abs.exists() { return Err(anyhow!("{} already exists", target_abs.display())); } fs::rename(&source_abs, &target_abs).with_context(|| { format!( "Failed to rename {} to {}", source_abs.display(), target_abs.display() ) })?; self.file_tree_mut() .refresh() .context("Failed to refresh file tree")?; self.file_tree_mut().reveal(&target_abs); self.ensure_focus_valid(); Ok(format!( "Renamed {} to {}", self.relative_tree_display(&original), self.relative_tree_display(&new_relative) )) } FileActionKind::Move { original } => { if original.as_os_str().is_empty() { return Err(anyhow!("Cannot move workspace root")); } let target = prompt.buffer.trim(); if target.is_empty() { return Err(anyhow!("Target path cannot be empty")); } let target_relative = PathBuf::from(target); validate_relative_path(&target_relative, true)?; let source_abs = self.absolute_tree_path(&original); let target_abs = self.absolute_tree_path(&target_relative); if target_abs.exists() { return Err(anyhow!("{} already exists", target_abs.display())); } if let Some(parent) = target_abs.parent() { fs::create_dir_all(parent).with_context(|| { format!( "Failed to create parent directories for {}", target_abs.display() ) })?; } fs::rename(&source_abs, &target_abs).with_context(|| { format!( "Failed to move {} to {}", source_abs.display(), target_abs.display() ) })?; self.file_tree_mut() .refresh() .context("Failed to refresh file tree")?; self.file_tree_mut().reveal(&target_abs); self.ensure_focus_valid(); Ok(format!( "Moved {} to {}", self.relative_tree_display(&original), self.relative_tree_display(&target_relative) )) } FileActionKind::Delete { target, confirm } => { if target.as_os_str().is_empty() { return Err(anyhow!("Cannot delete workspace root")); } let typed = prompt.buffer.trim(); if typed != confirm { return Err(anyhow!("Type '{}' to confirm deletion", confirm)); } let absolute = self.absolute_tree_path(&target); if absolute.is_dir() { fs::remove_dir_all(&absolute).with_context(|| { format!("Failed to delete directory {}", absolute.display()) })?; } else if absolute.exists() { fs::remove_file(&absolute) .with_context(|| format!("Failed to delete file {}", absolute.display()))?; } else { return Err(anyhow!("{} does not exist", absolute.display())); } self.file_tree_mut() .refresh() .context("Failed to refresh file tree")?; if let Some(parent) = target.parent() { let parent_abs = if parent.as_os_str().is_empty() { self.file_tree().root().to_path_buf() } else { self.absolute_tree_path(parent) }; self.file_tree_mut().reveal(&parent_abs); } self.ensure_focus_valid(); Ok(format!("Deleted {}", self.relative_tree_display(&target))) } } } fn reveal_path_in_file_tree(&mut self, path: &Path) { if !self.is_code_mode() { self.status = "Switch to code mode to reveal files".to_string(); self.error = None; return; } let absolute = self.absolute_tree_path(path); self.expand_file_panel(); self.file_tree_mut().reveal(&absolute); self.focused_panel = FocusedPanel::Files; self.ensure_focus_valid(); let display = absolute .strip_prefix(self.file_tree().root()) .map(|p| p.to_string_lossy().into_owned()) .unwrap_or_else(|_| absolute.to_string_lossy().into_owned()); self.status = format!("Revealed {}", display); } fn reveal_active_file(&mut self) { let path_opt = self.code_workspace.active_pane().and_then(|pane| { pane.absolute_path().map(Path::to_path_buf).or_else(|| { pane.display_path() .map(|display| PathBuf::from(display.to_string())) }) }); match path_opt { Some(path) => self.reveal_path_in_file_tree(&path), None => { self.status = "No active file to reveal".to_string(); } } } async fn handle_file_panel_key(&mut self, key: &crossterm::event::KeyEvent) -> Result { use crossterm::event::{KeyCode, KeyModifiers}; if !self.is_code_mode() { return Ok(false); } let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); let shift = key.modifiers.contains(KeyModifiers::SHIFT); let alt = key.modifiers.contains(KeyModifiers::ALT); let no_modifiers = key.modifiers.is_empty(); if self.pending_file_action.is_some() && self.handle_file_action_prompt(key)? { return Ok(true); } match key.code { KeyCode::Enter => { self.open_selected_file_from_tree(FileOpenDisposition::Primary) .await?; return Ok(true); } KeyCode::Char('o') if no_modifiers => { self.open_selected_file_from_tree(FileOpenDisposition::SplitHorizontal) .await?; return Ok(true); } KeyCode::Char('O') if shift && !ctrl && !alt => { self.open_selected_file_from_tree(FileOpenDisposition::SplitVertical) .await?; return Ok(true); } KeyCode::Char('t') if no_modifiers => { self.open_selected_file_from_tree(FileOpenDisposition::Tab) .await?; return Ok(true); } KeyCode::Char('y') if no_modifiers => { self.copy_selected_path(false); return Ok(true); } KeyCode::Char('Y') if shift && !ctrl && !alt => { self.copy_selected_path(true); return Ok(true); } KeyCode::Char('A') if shift && !ctrl && !alt => { if let Some(selected) = self.selected_file_node() { let base = if selected.is_dir { selected.path.clone() } else { selected .path .parent() .map(|p| p.to_path_buf()) .unwrap_or_else(PathBuf::new) }; self.begin_file_action(FileActionKind::CreateFolder { base }, String::new()); } else { self.status = "No file selected".to_string(); } return Ok(true); } KeyCode::Char('r') if no_modifiers => { if let Some(selected) = self.selected_file_node() { if selected.path.as_os_str().is_empty() { self.error = Some("Cannot rename workspace root".to_string()); } else { let initial = selected .path .file_name() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_default(); self.begin_file_action( FileActionKind::Rename { original: selected.path.clone(), }, initial, ); } } else { self.status = "No file selected".to_string(); } return Ok(true); } KeyCode::Char('m') if no_modifiers => { if let Some(selected) = self.selected_file_node() { if selected.path.as_os_str().is_empty() { self.error = Some("Cannot move workspace root".to_string()); } else { let initial = self.relative_tree_display(&selected.path); self.begin_file_action( FileActionKind::Move { original: selected.path.clone(), }, initial, ); } } else { self.status = "No file selected".to_string(); } return Ok(true); } KeyCode::Char('d') if no_modifiers => { if let Some(selected) = self.selected_file_node() { if selected.path.as_os_str().is_empty() { self.error = Some("Cannot delete workspace root".to_string()); } else { let confirm = selected .path .file_name() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_default(); if confirm.is_empty() { self.error = Some("Unable to determine file name for confirmation".to_string()); } else { self.begin_file_action( FileActionKind::Delete { target: selected.path.clone(), confirm, }, String::new(), ); } } } else { self.status = "No file selected".to_string(); } return Ok(true); } KeyCode::Char('.') if no_modifiers => { self.launch_external_editor().await?; return Ok(true); } KeyCode::Char('/') if !ctrl && !alt => { if self.file_tree().filter_query().is_empty() { { let tree = self.file_tree_mut(); tree.set_filter_mode(FileFilterMode::Fuzzy); tree.set_filter_query(String::new()); } let mode = match self.file_tree().filter_mode() { FileFilterMode::Glob => "glob", FileFilterMode::Fuzzy => "fuzzy", }; self.status = format!("Filter ({mode}): type to search"); self.error = None; } else { self.append_file_filter_char('/'); } return Ok(true); } KeyCode::Backspace if !ctrl && !alt => { self.backspace_file_filter(); return Ok(true); } KeyCode::Esc if !ctrl && !alt => { if !self.file_tree().filter_query().is_empty() { self.clear_file_filter(); return Ok(true); } } KeyCode::Char(' ') if !ctrl && !alt => { self.file_tree_mut().toggle_expand(); return Ok(true); } KeyCode::Char(c) if !ctrl && !alt => { let reserved = matches!( (c, shift), ('o', false) | ('O', true) | ('t', false) | ('y', false) | ('Y', true) | ('g', _) | ('d', _) | ('m', _) | ('A', true) | ('r', _) | ('/', _) | ('.', _) ); if !reserved && !c.is_control() { self.append_file_filter_char(c); return Ok(true); } } _ => {} } Ok(false) } fn handle_resize(&mut self, width: u16, _height: u16) { let approx_content_width = usize::from(width.saturating_sub(6)); self.content_width = approx_content_width.max(1); self.auto_scroll.stick_to_bottom = true; self.thinking_scroll.stick_to_bottom = true; if let Some(scroll) = self.code_view_scroll_mut() { scroll.stick_to_bottom = false; } } 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, scope_status) = self.collect_models_from_all_providers().await; self.models = all_models; self.provider_scope_status = scope_status; self.model_details_cache.clear(); self.model_info_panel.clear(); self.show_model_info = false; self.recompute_available_providers(); if self.available_providers.is_empty() { self.available_providers.push("ollama_local".to_string()); } if !config_model_provider.is_empty() { self.selected_provider = Self::canonical_provider_id(&config_model_provider); } 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("; ")); } self.update_command_palette_catalog(); Ok(()) } pub async fn handle_event(&mut self, event: Event) -> Result { use crossterm::event::{KeyCode, KeyModifiers}; if let Some(last) = self.last_ctrl_c && last.elapsed() > DOUBLE_CTRL_C_WINDOW { self.last_ctrl_c = None; } match event { Event::Tick => { self.poll_repo_search(); self.poll_symbol_search(); self.poll_debug_log_updates(); self.prune_toasts(); // Future: update streaming timers } Event::Resize(width, height) => { self.handle_resize(width, height); } Event::Paste(text) => { // Handle paste events - insert text directly without triggering sends if matches!(self.mode, InputMode::Editing | InputMode::Visual) && self.textarea.insert_str(&text) { self.sync_textarea_to_buffer(); } // Ignore paste events in other modes } Event::Mouse(mouse) => { return self.handle_mouse_event(mouse); } Event::Key(key) => { let is_ctrl_c = matches!( (key.code, key.modifiers), (KeyCode::Char('c'), m) if m.contains(KeyModifiers::CONTROL) ); if !is_ctrl_c { self.last_ctrl_c = None; } // Handle consent dialog first (highest priority) if let Some(consent_state) = &self.pending_consent { let scope = match key.code { KeyCode::Char('1') => Some(ConsentScope::Once), KeyCode::Char('2') => Some(ConsentScope::Session), KeyCode::Char('3') => Some(ConsentScope::Permanent), KeyCode::Char('4') | KeyCode::Esc => Some(ConsentScope::Denied), _ => None, }; if let Some(scope) = scope { let request_id = consent_state.request_id; let effects = self.apply_app_event(AppEvent::ToolPermission { request_id, scope }); self.handle_app_effects(effects).await?; } return Ok(AppState::Running); } if self.try_execute_command(&key).await? { return Ok(AppState::Running); } if matches!(key.code, KeyCode::F(1)) { if matches!(self.mode, InputMode::Help) { self.set_input_mode(InputMode::Normal); self.help_tab_index = 0; self.reset_status(); } else { self.set_input_mode(InputMode::Help); self.status = "Help".to_string(); self.error = None; } return Ok(AppState::Running); } let is_question_mark = matches!( (key.code, key.modifiers), (KeyCode::Char('?'), KeyModifiers::NONE | KeyModifiers::SHIFT) ); let is_reveal_active = key.modifiers.contains(KeyModifiers::CONTROL) && key.modifiers.contains(KeyModifiers::SHIFT) && matches!(key.code, KeyCode::Char('r') | KeyCode::Char('R')); let is_repo_search = key.modifiers.contains(KeyModifiers::CONTROL) && key.modifiers.contains(KeyModifiers::SHIFT) && matches!(key.code, KeyCode::Char('f') | KeyCode::Char('F')); let is_symbol_search_key = key.modifiers.contains(KeyModifiers::CONTROL) && key.modifiers.contains(KeyModifiers::SHIFT) && matches!(key.code, KeyCode::Char('p') | KeyCode::Char('P')); let is_resize_left = key.modifiers.contains(KeyModifiers::CONTROL) && matches!( key.code, KeyCode::Left | KeyCode::Char('h') | KeyCode::Char('H') ); let is_resize_right = key.modifiers.contains(KeyModifiers::CONTROL) && matches!( key.code, KeyCode::Right | KeyCode::Char('l') | KeyCode::Char('L') ); let is_resize_up = key.modifiers.contains(KeyModifiers::CONTROL) && matches!( key.code, KeyCode::Up | KeyCode::Char('k') | KeyCode::Char('K') ); let is_resize_down = key.modifiers.contains(KeyModifiers::CONTROL) && matches!( key.code, KeyCode::Down | KeyCode::Char('j') | KeyCode::Char('J') ); if is_reveal_active && matches!(self.mode, InputMode::Normal) { self.reveal_active_file(); return Ok(AppState::Running); } if is_question_mark && matches!(self.mode, InputMode::Normal) { self.set_input_mode(InputMode::Help); self.status = "Help".to_string(); return Ok(AppState::Running); } if is_repo_search && matches!(self.mode, InputMode::Normal) { self.set_input_mode(InputMode::RepoSearch); if self.repo_search.query_input().is_empty() { *self.repo_search.status_mut() = Some("Type a pattern · Enter runs ripgrep".to_string()); } self.status = "Repo search active".to_string(); return Ok(AppState::Running); } if (is_resize_left || is_resize_right) && matches!(self.mode, InputMode::Normal) && !self.is_file_panel_collapsed() { let current = self.file_panel_width(); let delta: i16 = if is_resize_left { -2 } else { 2 }; let candidate = current.saturating_add_signed(delta); let adjusted = self.set_file_panel_width(candidate); if adjusted != current { self.status = format!("Files panel width: {} cols", adjusted); self.error = None; } else { self.status = "Files panel width unchanged".to_string(); } return Ok(AppState::Running); } if (is_resize_up || is_resize_down) && matches!(self.mode, InputMode::Normal) { let delta = if is_resize_up { -0.05 } else { 0.05 }; self.adjust_vertical_split(delta); self.status = format!( "Vertical split adjusted ({})", if delta.is_sign_positive() { "down" } else { "up" } ); self.error = None; return Ok(AppState::Running); } if is_symbol_search_key && matches!(self.mode, InputMode::Normal) { self.set_input_mode(InputMode::SymbolSearch); self.symbol_search.clear_query(); self.status = "Symbol search active".to_string(); self.start_symbol_search().await?; return Ok(AppState::Running); } match self.mode { InputMode::Normal => { // Handle multi-key sequences first if self.show_model_info && matches!( (key.code, key.modifiers), (KeyCode::Esc, KeyModifiers::NONE) ) { self.set_model_info_visible(false); self.status = "Closed model info panel".to_string(); return Ok(AppState::Running); } if let Some(started) = self.pending_focus_chord { if started.elapsed() > FOCUS_CHORD_TIMEOUT { self.pending_focus_chord = None; } else if key.modifiers.is_empty() { let direction = match key.code { KeyCode::Left => Some(PaneDirection::Left), KeyCode::Right => Some(PaneDirection::Right), KeyCode::Up => Some(PaneDirection::Up), KeyCode::Down => Some(PaneDirection::Down), _ => None, }; if let Some(direction) = direction { self.handle_workspace_focus_move(direction); return Ok(AppState::Running); } else { self.pending_focus_chord = None; } } else { self.pending_focus_chord = None; } } if let Some(pending) = self.pending_key { self.pending_key = None; match (pending, key.code) { ('g', KeyCode::Char('g')) => { self.jump_to_top(); } ('g', KeyCode::Char('T')) | ('g', KeyCode::Char('t')) => { self.expand_file_panel(); self.focused_panel = FocusedPanel::Files; self.status = "Files panel focused".to_string(); } ('g', KeyCode::Char('h')) | ('g', KeyCode::Char('H')) => { if matches!(self.focused_panel, FocusedPanel::Files) { self.toggle_hidden_files(); } else { self.status = "Toggle hidden files from the Files panel".to_string(); } } ('W', KeyCode::Char('s')) | ('W', KeyCode::Char('S')) => { self.split_active_pane(SplitAxis::Horizontal); } ('W', KeyCode::Char('v')) | ('W', KeyCode::Char('V')) => { self.split_active_pane(SplitAxis::Vertical); } ('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); } if matches!(self.focused_panel, FocusedPanel::Files) && self.handle_file_panel_key(&key).await? { return Ok(AppState::Running); } if key.modifiers.contains(KeyModifiers::CONTROL) && matches!(key.code, KeyCode::Char('w') | KeyCode::Char('W')) { self.pending_key = Some('W'); self.status = "Split layout: press s for horizontal, v for vertical".to_string(); return Ok(AppState::Running); } match (key.code, key.modifiers) { (KeyCode::Left, modifiers) if modifiers.contains(KeyModifiers::ALT) => { self.handle_workspace_resize(PaneDirection::Left); return Ok(AppState::Running); } (KeyCode::Right, modifiers) if modifiers.contains(KeyModifiers::ALT) => { self.handle_workspace_resize(PaneDirection::Right); return Ok(AppState::Running); } (KeyCode::Up, modifiers) if modifiers.contains(KeyModifiers::ALT) => { self.handle_workspace_resize(PaneDirection::Up); return Ok(AppState::Running); } (KeyCode::Down, modifiers) if modifiers.contains(KeyModifiers::ALT) => { self.handle_workspace_resize(PaneDirection::Down); return Ok(AppState::Running); } (KeyCode::Char('c'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) => { if self.cancel_active_generation()? { self.last_ctrl_c = None; return Ok(AppState::Running); } let now = Instant::now(); if let Some(last) = self.last_ctrl_c && now.duration_since(last) <= DOUBLE_CTRL_C_WINDOW { self.status = "Exiting…".to_string(); self.set_system_status(String::new()); self.last_ctrl_c = None; return Ok(AppState::Quit); } self.last_ctrl_c = Some(now); self.status = "Press Ctrl+C again to quit".to_string(); self.set_system_status( "Press Ctrl+C again to quit OWLEN".to_string(), ); return Ok(AppState::Running); } (KeyCode::Char('j'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) => { if self.show_model_info && self.model_info_viewport_height > 0 { self.model_info_panel .scroll_down(self.model_info_viewport_height); } } (KeyCode::Char('k'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) => { self.pending_focus_chord = Some(Instant::now()); self.status = "Pane focus pending — use ←/→/↑/↓".to_string(); if self.show_model_info && self.model_info_viewport_height > 0 { self.model_info_panel.scroll_up(); } return Ok(AppState::Running); } // Mode switches (KeyCode::Char('v'), KeyModifiers::NONE) => { if matches!(self.focused_panel, FocusedPanel::Code) { self.status = "Code view is read-only; yank text with :open and copy manually." .to_string(); return Ok(AppState::Running); } self.set_input_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 let selection_style = Style::default() .bg(self.theme.selection_bg) .fg(self.theme.selection_fg); self.textarea.set_selection_style(selection_style); // 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); } FocusedPanel::Files => {} FocusedPanel::Code => {} } self.status = "-- VISUAL -- (move with j/k, yank with y)".to_string(); } (KeyCode::Char(':'), KeyModifiers::NONE) => { self.set_input_mode(InputMode::Command); self.command_palette.clear(); self.command_palette.ensure_suggestions(); self.status = ":".to_string(); } (KeyCode::Char('p'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) => { self.set_input_mode(InputMode::Command); self.command_palette.clear(); self.command_palette.ensure_suggestions(); self.status = ":".to_string(); return Ok(AppState::Running); } // Enter editing mode (KeyCode::Enter, KeyModifiers::NONE) | (KeyCode::Char('i'), KeyModifiers::NONE) => { self.set_input_mode(InputMode::Editing); self.sync_buffer_to_textarea(); } (KeyCode::Char('a'), KeyModifiers::NONE) => { // Append - move right and enter insert mode self.set_input_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.set_input_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.set_input_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.set_input_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.set_input_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::Files => { self.file_tree_mut().move_cursor(-1); } FocusedPanel::Code => { let viewport = self.code_view_viewport_height().max(1); if let Some(scroll) = self.code_view_scroll_mut() && scroll.scroll > 0 { scroll.on_user_scroll(-1, viewport); } } 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::Files => { self.file_tree_mut().move_cursor(1); } FocusedPanel::Code => { let viewport = self.code_view_viewport_height().max(1); if let Some(scroll) = self.code_view_scroll_mut() { let max_lines = scroll.content_len; if scroll.scroll + viewport < max_lines { scroll.on_user_scroll(1, viewport); } } } 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; } } FocusedPanel::Code => {} _ => {} } } (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; } } } FocusedPanel::Code => {} _ => {} } } // 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; } } FocusedPanel::Code => {} _ => {} }, (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; } } FocusedPanel::Code => {} _ => {} }, (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; } } FocusedPanel::Code => {} _ => {} }, (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; } } FocusedPanel::Code => {} _ => {} }, // 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; } FocusedPanel::Code => {} _ => {} }, (KeyCode::Char('$'), 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(); } } FocusedPanel::Code => {} _ => {} }, (KeyCode::End, KeyModifiers::NONE) => match self.focused_panel { FocusedPanel::Chat => { self.jump_to_bottom(); } FocusedPanel::Thinking => { let viewport_height = self.thinking_viewport_height.max(1); self.thinking_scroll.jump_to_bottom(viewport_height); } FocusedPanel::Files => { self.file_tree_mut().jump_to_bottom(); } FocusedPanel::Code => { let viewport = self.code_view_viewport_height().max(1); if let Some(scroll) = self.code_view_scroll_mut() { scroll.jump_to_bottom(viewport); } } FocusedPanel::Input => {} }, // 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::Files => "Files", FocusedPanel::Chat => "Chat", FocusedPanel::Thinking => "Thinking", FocusedPanel::Input => "Input", FocusedPanel::Code => "Code", }; self.status = format!("Focus: {}", panel_name); } (KeyCode::BackTab, KeyModifiers::SHIFT) => { self.cycle_focus_backward(); let panel_name = match self.focused_panel { FocusedPanel::Files => "Files", FocusedPanel::Chat => "Chat", FocusedPanel::Thinking => "Thinking", FocusedPanel::Input => "Input", FocusedPanel::Code => "Code", }; self.status = format!("Focus: {}", panel_name); } (KeyCode::Char('1'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) || modifiers.contains(KeyModifiers::ALT) => { if self.focus_panel(FocusedPanel::Files) { self.status = "Focus: Files (Ctrl+1)".to_string(); self.error = None; } else if self.is_code_mode() { self.status = "Files panel is collapsed — use :files to reopen" .to_string(); } return Ok(AppState::Running); } (KeyCode::Char('2'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) || modifiers.contains(KeyModifiers::ALT) => { if self.focus_panel(FocusedPanel::Chat) { self.status = "Focus: Chat (Ctrl+2)".to_string(); self.error = None; } return Ok(AppState::Running); } (KeyCode::Char('3'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) || modifiers.contains(KeyModifiers::ALT) => { if self.focus_panel(FocusedPanel::Code) { self.status = "Focus: Code (Ctrl+3)".to_string(); self.error = None; } else { self.status = "Open a file to focus the code workspace".to_string(); } return Ok(AppState::Running); } (KeyCode::Char('4'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) || modifiers.contains(KeyModifiers::ALT) => { if self.focus_panel(FocusedPanel::Thinking) { self.status = "Focus: Thinking (Ctrl+4)".to_string(); self.error = None; } else { self.status = "No reasoning panel to focus yet".to_string(); } return Ok(AppState::Running); } (KeyCode::Char('5'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) || modifiers.contains(KeyModifiers::ALT) => { if self.focus_panel(FocusedPanel::Input) { self.status = "Focus: Input (Ctrl+5) — press i to edit".to_string(); self.error = None; } return Ok(AppState::Running); } (KeyCode::Char('m'), KeyModifiers::NONE) => { if let Err(err) = self.show_model_picker(None).await { self.error = Some(err.to_string()); } return Ok(AppState::Running); } (KeyCode::Esc, KeyModifiers::NONE) => { self.pending_key = None; self.set_input_mode(InputMode::Normal); } _ => { self.pending_key = None; } } } InputMode::RepoSearch => match (key.code, key.modifiers) { (KeyCode::Esc, _) => { self.set_input_mode(InputMode::Normal); self.status = "Normal mode".to_string(); } (KeyCode::Enter, modifiers) if modifiers.contains(KeyModifiers::ALT) => { self.open_repo_search_scratch().await?; } (KeyCode::Enter, _) => { if self.repo_search.running() { self.status = "Search already running".to_string(); } else if self.repo_search.dirty() || !self.repo_search.has_results() { self.start_repo_search().await?; } else { self.open_repo_search_match().await?; } } (KeyCode::Backspace, modifiers) if !modifiers.contains(KeyModifiers::CONTROL) && !modifiers.contains(KeyModifiers::ALT) => { self.repo_search.pop_query_char(); *self.repo_search.status_mut() = Some("Press Enter to search".to_string()); self.status = format!("Query: {}", self.repo_search.query_input()); } (KeyCode::Char('u'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) => { self.repo_search.clear_query(); *self.repo_search.status_mut() = Some("Query cleared".to_string()); self.status = "Query cleared".to_string(); } (KeyCode::Delete, _) => { self.repo_search.clear_query(); *self.repo_search.status_mut() = Some("Query cleared".to_string()); self.status = "Query cleared".to_string(); } (KeyCode::Char(c), modifiers) if !modifiers.contains(KeyModifiers::CONTROL) && !modifiers.contains(KeyModifiers::ALT) && !c.is_control() => { self.repo_search.push_query_char(c); *self.repo_search.status_mut() = Some("Press Enter to search".to_string()); self.status = format!("Query: {}", self.repo_search.query_input()); } (KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::NONE) => { self.repo_search.move_selection(-1); } (KeyCode::Down, _) | (KeyCode::Char('j'), KeyModifiers::NONE) | (KeyCode::Char('n'), KeyModifiers::CONTROL) => { self.repo_search.move_selection(1); } (KeyCode::PageUp, _) => { self.repo_search.page(-1); } (KeyCode::PageDown, _) => { self.repo_search.page(1); } (KeyCode::Home, _) => { self.repo_search.scroll_to(0); } (KeyCode::End, _) => { let max = self.repo_search.max_scroll(); self.repo_search.scroll_to(max); } _ => {} }, InputMode::SymbolSearch => match (key.code, key.modifiers) { (KeyCode::Esc, _) => { self.set_input_mode(InputMode::Normal); self.status = "Normal mode".to_string(); } (KeyCode::Enter, _) => { if self.symbol_search.is_running() { self.status = "Symbol index still building".to_string(); } else { self.open_symbol_search_entry().await?; } } (KeyCode::Backspace, modifiers) if !modifiers.contains(KeyModifiers::CONTROL) && !modifiers.contains(KeyModifiers::ALT) => { self.symbol_search.pop_query_char(); self.status = format!("Symbol filter: {}", self.symbol_search.query()); } (KeyCode::Char('u'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) => { self.symbol_search.clear_query(); self.status = "Symbol query cleared".to_string(); } (KeyCode::Delete, _) => { self.symbol_search.clear_query(); self.status = "Symbol query cleared".to_string(); } (KeyCode::Char(c), modifiers) if !modifiers.contains(KeyModifiers::CONTROL) && !modifiers.contains(KeyModifiers::ALT) && !c.is_control() => { self.symbol_search.push_query_char(c); self.status = format!("Symbol filter: {}", self.symbol_search.query()); } (KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::NONE) => { self.symbol_search.move_selection(-1); } (KeyCode::Down, _) | (KeyCode::Char('j'), KeyModifiers::NONE) | (KeyCode::Char('n'), KeyModifiers::CONTROL) => { self.symbol_search.move_selection(1); } (KeyCode::PageUp, _) => { self.symbol_search.page(-1); } (KeyCode::PageDown, _) => { self.symbol_search.page(1); } _ => {} }, InputMode::Editing => match (key.code, key.modifiers) { (KeyCode::Char('p'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) => { self.sync_textarea_to_buffer(); self.set_input_mode(InputMode::Command); self.command_palette.clear(); self.command_palette.ensure_suggestions(); self.status = ":".to_string(); } (KeyCode::Char('c'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) => { let _ = self.cancel_active_generation()?; self.sync_textarea_to_buffer(); self.set_input_mode(InputMode::Normal); self.reset_status(); } (KeyCode::Esc, KeyModifiers::NONE) => { // Sync textarea content to input buffer before leaving edit mode self.sync_textarea_to_buffer(); self.set_input_mode(InputMode::Normal); self.reset_status(); } (KeyCode::Char('['), modifiers) if modifiers.contains(KeyModifiers::CONTROL) => { self.sync_textarea_to_buffer(); self.set_input_mode(InputMode::Normal); self.reset_status(); } (KeyCode::Char('j' | 'J'), m) if m.contains(KeyModifiers::CONTROL) => { self.textarea.insert_newline(); } (KeyCode::Enter, KeyModifiers::NONE) => { self.sync_textarea_to_buffer(); let effects = self.apply_app_event(AppEvent::Composer(ComposerEvent::Submit)); self.handle_app_effects(effects).await?; return Ok(AppState::Running); } (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::Tab, m) if m.is_empty() => { if !self.complete_resource_reference() { self.textarea.input(Input::from(key)); } } (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.set_input_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(); } } FocusedPanel::Files => {} FocusedPanel::Code => {} } self.set_input_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(); } } FocusedPanel::Files => {} FocusedPanel::Code => {} } self.set_input_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 && col > 0 { self.visual_end = Some((row, col - 1)); } } FocusedPanel::Files => {} FocusedPanel::Code => {} } } (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)); } } FocusedPanel::Files => {} FocusedPanel::Code => {} } } (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 && row > 0 { self.visual_end = Some((row - 1, col)); // Scroll if needed to keep selection visible self.on_scroll(-1); } } FocusedPanel::Files => {} FocusedPanel::Code => {} } } (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); } } } FocusedPanel::Files => {} FocusedPanel::Code => {} } } (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)); } } } FocusedPanel::Files => {} FocusedPanel::Code => {} } } (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)); } } } FocusedPanel::Files => {} FocusedPanel::Code => {} } } (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)); } } FocusedPanel::Files => {} FocusedPanel::Code => {} } } (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)); } } } FocusedPanel::Files => {} FocusedPanel::Code => {} } } _ => { // Ignore all other input in visual mode (no typing allowed) } }, InputMode::Command => match (key.code, key.modifiers) { (KeyCode::Esc, _) => { self.set_input_mode(InputMode::Normal); self.command_palette.clear(); self.reset_status(); } (KeyCode::Tab, _) => { // Tab completion self.complete_command(); } (KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::CONTROL) => { // Navigate up in suggestions self.command_palette.select_previous(); } (KeyCode::Down, _) | (KeyCode::Char('j'), KeyModifiers::CONTROL) => { // Navigate down in suggestions self.command_palette.select_next(); } (KeyCode::Enter, _) => { // Execute command let cmd_owned = self.command_palette.buffer().trim().to_string(); let parts: Vec<&str> = cmd_owned.split_whitespace().collect(); let command_raw = parts.first().copied().unwrap_or(""); let args = &parts[1..]; if !cmd_owned.is_empty() { self.command_palette.remember(&cmd_owned); } let bare_command = command_raw.trim_end_matches('!'); let force = bare_command.len() != command_raw.len(); match bare_command { "" => {} "wq" | "x" => { let path_arg = if args.is_empty() { None } else { Some(args.join(" ")) }; let result = self.save_active_code_buffer(path_arg, force).await?; if matches!(result, SaveStatus::Saved | SaveStatus::NoChanges) { self.close_active_code_buffer(force); } } "w" | "write" | "save" => { let path_arg = if args.is_empty() { None } else { Some(args.join(" ")) }; let _ = self.save_active_code_buffer(path_arg, force).await?; } "q" => { if matches!(self.focused_panel, FocusedPanel::Files) && !self.is_file_panel_collapsed() { self.collapse_file_panel(); self.status = "Files panel hidden".to_string(); self.error = None; } else { self.close_active_code_buffer(force); } } "quit" => { if matches!(self.focused_panel, FocusedPanel::Files) && !self.is_file_panel_collapsed() { self.collapse_file_panel(); self.status = "Files panel hidden".to_string(); self.error = None; } else { return Ok(AppState::Quit); } } "create" => { if !self.is_code_mode() { self.status = "File operations are available in code mode" .to_string(); self.error = None; self.set_input_mode(InputMode::Normal); self.command_palette.clear(); return Ok(AppState::Running); } if args.is_empty() { self.error = Some("Usage: :create ".to_string()); } else { let path_arg = args.join(" "); match self.create_file_from_command(&path_arg) { Ok(message) => { self.status = message; self.error = None; } Err(err) => { self.status = "File creation failed".to_string(); self.error = Some(err.to_string()); } } } } "files" | "explorer" => { if !self.is_code_mode() { self.status = "File explorer is available in code mode".to_string(); self.error = None; self.set_input_mode(InputMode::Normal); self.command_palette.clear(); return Ok(AppState::Running); } let was_collapsed = self.is_file_panel_collapsed(); self.toggle_file_panel(); let now_collapsed = self.is_file_panel_collapsed(); self.error = None; if was_collapsed && !now_collapsed { self.status = "Files panel shown".to_string(); } else if !was_collapsed && now_collapsed { self.status = "Files panel hidden".to_string(); } else { self.status = "Files panel unchanged".to_string(); } } "markdown" => { let desired = if let Some(arg) = args.first() { match arg.to_ascii_lowercase().as_str() { "on" | "enable" | "enabled" | "true" => Some(true), "off" | "disable" | "disabled" | "false" => Some(false), "toggle" => None, other => { self.error = Some(format!( "Unknown markdown option '{}'. Use on, off, or toggle.", other )); self.status = "Usage: :markdown [on|off|toggle]".to_string(); self.set_input_mode(InputMode::Normal); self.command_palette.clear(); return Ok(AppState::Running); } } } else { None }; let enable = desired.unwrap_or_else(|| !self.render_markdown_enabled()); self.set_render_markdown(enable); self.set_input_mode(InputMode::Normal); self.command_palette.clear(); return Ok(AppState::Running); } "c" | "clear" => { self.controller.clear(); self.chat_line_offset = 0; self.auto_scroll = AutoScroll::default(); self.clear_new_message_alert(); self.status = "Conversation cleared".to_string(); } "session" => { if let Some(subcommand) = args.first() { match subcommand.to_ascii_lowercase().as_str() { "save" => { let name = if args.len() > 1 { Some(args[1..].join(" ")) } else { None }; let description = if self .controller .config() .storage .generate_descriptions { self.status = "Generating description...".to_string(); (self .controller .generate_conversation_description() .await) .ok() } else { None }; 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 )); } } } other => { self.error = Some(format!( "Unknown session subcommand: {}", other )); } } } else { self.status = "Session commands: :session save [name]".to_string(); self.error = None; } } "oauth" => { if args.is_empty() { let pending = self.controller.pending_oauth_servers(); if pending.is_empty() { self.status = "No OAuth-enabled MCP servers require authorization." .to_string(); } else { self.status = format!( "Pending OAuth servers: {}", pending.join(", ") ); } self.error = None; } else if args.len() == 1 { self.start_oauth_login(args[0]).await?; } else if args.len() == 2 && args[0].eq_ignore_ascii_case("login") { self.start_oauth_login(args[1]).await?; } else { self.error = Some("Usage: :oauth [login] ".to_string()); } } "load" | "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.set_input_mode(InputMode::SessionBrowser); self.command_palette.clear(); return Ok(AppState::Running); } Err(e) => { self.error = Some(format!("Failed to list sessions: {}", e)); } } } "open" => { if let Some(path) = args.first() { if !matches!( self.operating_mode, owlen_core::mode::Mode::Code ) { self.error = Some( "Code view requires code mode. Run :mode code first." .to_string(), ); } else { match self.controller.read_file_with_tools(path).await { Ok(content) => { let absolute = self.absolute_tree_path(Path::new(path)); self.set_code_view_content( path.to_string(), Some(absolute), content, ); self.focused_panel = FocusedPanel::Code; self.ensure_focus_valid(); self.status = format!("Opened {}", path); self.set_system_status(format!( "Viewing {}", path )); self.error = None; } Err(e) => { self.error = Some(format!("Failed to open file: {}", e)); } } } } else { self.error = Some("Usage: :open ".to_string()); } } "close" => { if self.has_loaded_code_view() { self.close_code_view(); self.status = "Closed code view".to_string(); self.set_system_status(String::new()); self.error = None; } else { self.status = "No code view active".to_string(); } } "sessions" => { // List saved sessions match self.controller.list_saved_sessions().await { Ok(sessions) => { self.saved_sessions = sessions; self.selected_session_index = 0; self.set_input_mode(InputMode::SessionBrowser); self.command_palette.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.set_input_mode(InputMode::Help); self.status = "Help".to_string(); self.error = None; self.command_palette.clear(); return Ok(AppState::Running); } "m" | "model" => { if args.is_empty() { if let Err(err) = self.show_model_picker(None).await { self.error = Some(err.to_string()); } self.command_palette.clear(); return Ok(AppState::Running); } let subcommand = args[0].to_lowercase(); match subcommand.as_str() { "info" | "details" | "refresh" => { let outcome: Result<()> = match subcommand.as_str() { "info" => { let target = if args.len() > 1 { args[1..].join(" ") } else { self.controller.selected_model().to_string() }; if target.trim().is_empty() { Err(anyhow!("Usage: :model info ")) } else { self.ensure_model_details(&target, false) .await } } "details" => { let target = self .controller .selected_model() .to_string(); if target.trim().is_empty() { Err(anyhow!( "No active model set. Use :model to choose one first" )) } else { self.ensure_model_details(&target, false) .await } } _ => { let target = if args.len() > 1 { args[1..].join(" ") } else { self.controller.selected_model().to_string() }; if target.trim().is_empty() { Err(anyhow!("Usage: :model refresh ")) } else { self.ensure_model_details(&target, true) .await } } }; match outcome { Ok(_) => self.error = None, Err(err) => self.error = Some(err.to_string()), } self.set_input_mode(InputMode::Normal); self.command_palette.clear(); return Ok(AppState::Running); } _ => { let filter = args.join(" "); match self.select_model_with_filter(&filter).await { Ok(_) => self.error = None, Err(err) => { self.status = err.to_string(); self.error = Some(err.to_string()); } } self.set_input_mode(InputMode::Normal); self.command_palette.clear(); return Ok(AppState::Running); } } } "provider" => { if args.is_empty() { self.error = Some("Usage: :provider ".to_string()); self.status = "Usage: :provider ".to_string(); } else { let provider_query = args[0].to_string(); let mode_arg = args.get(1).map(|value| value.to_string()); if let Some(mode_value) = mode_arg { if let Some(provider) = self.best_provider_match(&provider_query) { match self .apply_provider_mode(&provider, &mode_value) .await { Ok(_) => { self.selected_provider = provider.clone(); self.update_selected_provider_index(); } Err(err) => { self.error = Some(err.to_string()); self.status = err.to_string(); } } } else { self.error = Some(format!( "No provider matching '{}'", provider_query )); self.status = format!( "No provider matching '{}'", provider_query.trim() ); } } else { if self.available_providers.is_empty() { if let Err(err) = self.refresh_models().await { self.error = Some(format!( "Failed to refresh providers: {}", err )); self.status = "Unable to refresh providers".to_string(); } } let filter = provider_query; if let Some(provider) = self.best_provider_match(&filter) { match self.switch_to_provider(&provider).await { Ok(_) => { self.selected_provider = provider.clone(); self.update_selected_provider_index(); self.controller .config_mut() .general .default_provider = provider.clone(); match config::save_config( &self.controller.config(), ) { Ok(_) => self.error = None, Err(err) => { self.error = Some(format!( "Provider switched but config save failed: {}", err )); self.status = "Provider switch saved with warnings" .to_string(); } } self.status = format!( "Active provider: {}", provider ); if let Err(err) = self.refresh_models().await { self.error = Some(format!( "Provider switched but refreshing models failed: {}", err )); self.status = "Provider switched; failed to refresh models" .to_string(); } self.context_usage = None; self.refresh_usage_summary().await?; } Err(err) => { self.error = Some(format!( "Failed to switch provider: {}", err )); self.status = "Provider switch failed".to_string(); } } } else { self.error = Some(format!( "No provider matching '{}'", filter )); self.status = format!( "No provider matching '{}'", filter.trim() ); } } } self.set_input_mode(InputMode::Normal); self.command_palette.clear(); return Ok(AppState::Running); } "limits" => { self.show_usage_limits().await?; self.set_input_mode(InputMode::Normal); self.command_palette.clear(); return Ok(AppState::Running); } "models" => { if args.is_empty() { if let Err(err) = self.show_model_picker(None).await { self.error = Some(err.to_string()); } self.command_palette.clear(); return Ok(AppState::Running); } match args[0] { "--local" => { if let Err(err) = self .show_model_picker(Some(FilterMode::LocalOnly)) .await { self.error = Some(err.to_string()); } else if !self .focus_first_model_in_scope(&ModelScope::Local) { self.status = "No local models available".to_string(); } else { self.status = "Showing local models".to_string(); self.error = None; } self.command_palette.clear(); return Ok(AppState::Running); } "--cloud" => { if let Err(err) = self .show_model_picker(Some(FilterMode::CloudOnly)) .await { self.error = Some(err.to_string()); } else if !self .focus_first_model_in_scope(&ModelScope::Cloud) { self.status = "No cloud models available".to_string(); } else { self.status = "Showing cloud models".to_string(); self.error = None; } self.command_palette.clear(); return Ok(AppState::Running); } "--available" => { if let Err(err) = self .show_model_picker(Some(FilterMode::Available)) .await { self.error = Some(err.to_string()); } else if !self.focus_first_available_model() { self.status = "No available models right now".to_string(); } else { self.status = "Showing available models".to_string(); self.error = None; } self.command_palette.clear(); return Ok(AppState::Running); } "info" => { let force_refresh = args .get(1) .map(|flag| { matches!(*flag, "refresh" | "-r" | "--refresh") }) .unwrap_or(false); let outcome = self .prefetch_all_model_details(force_refresh) .await; match outcome { Ok(_) => self.error = None, Err(err) => self.error = Some(err.to_string()), } self.set_input_mode(InputMode::Normal); self.command_palette.clear(); return Ok(AppState::Running); } _ => { self.error = Some( "Usage: :models [--local|--cloud|info]".to_string(), ); self.status = "Usage: :models [--local|--cloud|info]".to_string(); } } self.set_input_mode(InputMode::Normal); self.command_palette.clear(); return Ok(AppState::Running); } // "run-agent" command removed to break circular dependency on owlen-cli. "agent" => { if let Some(subcommand) = args.first() { match subcommand.to_lowercase().as_str() { "status" => { let armed = if self.agent_mode { "armed" } else { "idle" }; let running = if self.agent_running { "running" } else { "stopped" }; self.status = format!("Agent status: {armed} · {running}"); self.error = None; } "start" | "arm" => { if self.agent_running { self.status = "Agent is already running".to_string(); } else { self.agent_mode = true; self.status = "Agent armed. Next message will be processed by the agent.".to_string(); self.error = None; } } "stop" => { if self.agent_running { self.agent_running = false; self.agent_mode = false; self.agent_actions = None; self.status = "Agent execution stopped".to_string(); self.error = None; } else if self.agent_mode { self.agent_mode = false; self.agent_actions = None; self.status = "Agent disarmed".to_string(); self.error = None; } else { self.status = "No agent is currently running".to_string(); } } other => { self.error = Some(format!("Unknown agent command: {other}")); } } } else 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(); self.error = None; } } "stop-agent" => { if self.agent_running { self.agent_running = false; self.agent_mode = false; self.agent_actions = None; self.status = "Agent execution stopped".to_string(); self.error = None; } else if self.agent_mode { self.agent_mode = false; self.agent_actions = None; self.status = "Agent disarmed".to_string(); self.error = None; } else { self.status = "No agent is currently running".to_string(); } } "n" | "new" => { self.controller.start_new_conversation(None, None); self.reset_after_new_conversation()?; self.status = "Started new conversation".to_string(); self.error = None; } "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 } } } } "tutorial" => { self.show_tutorial(); } "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.set_input_mode(InputMode::ThemeBrowser); self.command_palette.clear(); return Ok(AppState::Running); } "layout" => { if let Some(subcommand) = args.first() { match subcommand.to_lowercase().as_str() { "save" => { if self.code_workspace.tabs().is_empty() { self.status = "No open panes to save".to_string(); self.error = None; self.push_toast( ToastLevel::Warning, "Open a pane before saving layout.", ); } else { self.persist_workspace_layout(); self.status = "Workspace layout saved".to_string(); self.error = None; self.push_toast( ToastLevel::Success, "Workspace layout saved.", ); } } "load" => match self.restore_workspace_layout().await { Ok(true) => { self.status = "Workspace layout restored".to_string(); self.error = None; self.push_toast( ToastLevel::Success, "Workspace layout restored.", ); } Ok(false) => { self.status = "No saved layout to restore".to_string(); self.error = None; self.push_toast( ToastLevel::Info, "No saved layout was found.", ); } Err(err) => { let message = format!( "Failed to restore workspace layout: {}", err ); self.error = Some(message.clone()); self.status = "Failed to restore workspace layout" .to_string(); self.push_toast(ToastLevel::Error, message); } }, other => { self.status = format!("Unknown layout command: {other}"); self.error = Some(format!( "Unknown layout subcommand: {other}" )); } } } else { self.status = "Usage: :layout ".to_string(); } } "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; self.sync_ui_preferences_from_config(); self.update_command_palette_catalog(); if let Err(err) = self.refresh_resource_catalog().await { self.push_toast( ToastLevel::Error, format!( "Failed to refresh MCP resources: {}", err ), ); } if let Err(err) = self.refresh_mcp_slash_commands().await { self.push_toast( ToastLevel::Error, format!( "Failed to refresh MCP slash commands: {}", err ), ); } } Err(e) => { self.error = Some(format!("Failed to reload config: {}", e)); } } } "cloud" => { match self.handle_cloud_command(args).await { Ok(_) => { if self.error.is_some() { // leave existing error } else { self.error = None; } } Err(err) => { let message = err.to_string(); if self.status.trim().is_empty() { self.status = message.clone(); } self.error = Some(message); } } self.command_palette.clear(); self.set_input_mode(InputMode::Normal); return Ok(AppState::Running); } "web" => { let action = args.get(0).map(|value| value.to_ascii_lowercase()); match action.as_deref() { Some("on") | Some("enable") => { match self.set_web_tool_enabled(true).await { Ok(_) => { self.status = "Web search enabled; remote lookups allowed." .to_string(); self.error = None; self.push_toast( ToastLevel::Info, "Web search enabled; remote lookups allowed.", ); } Err(err) => { self.status = "Failed to enable web search".to_string(); self.error = Some(format!( "Failed to enable web search: {}", err )); } } } Some("off") | Some("disable") => { match self.set_web_tool_enabled(false).await { Ok(_) => { self.status = "Web search disabled; staying local." .to_string(); self.error = None; self.push_toast( ToastLevel::Warning, "Web search disabled; staying local.", ); } Err(err) => { self.status = "Failed to disable web search".to_string(); self.error = Some(format!( "Failed to disable web search: {}", err )); } } } Some("status") | None => { { let config = self.controller.config(); let enabled = config.tools.web_search.enabled && config.privacy.enable_remote_search; if enabled { self.status = "Web search is enabled.".to_string(); } else { self.status = "Web search is disabled.".to_string(); } } self.error = None; } _ => { self.status = "Usage: :web ".to_string(); self.error = Some("Usage: :web ".to_string()); } } self.command_palette.clear(); self.set_input_mode(InputMode::Normal); return Ok(AppState::Running); } "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)); } } } "keymap" => { if let Some(arg) = args.first() { match KeymapProfile::from_str(arg) { Some(profile) if profile.is_builtin() => { self.switch_keymap_profile(profile).await?; } Some(_) => { self.error = Some( "Custom keymaps must be configured via keymap_path".to_string(), ); } None => { self.error = Some(format!( "Unknown keymap profile: {}", arg )); } } } else { self.status = format!( "Active keymap: {}", self.current_keymap_profile().label() ); self.error = None; } } _ => { self.error = Some(format!("Unknown command: {}", cmd_owned)); } } self.command_palette.clear(); self.set_input_mode(InputMode::Normal); } (KeyCode::Char(c), KeyModifiers::NONE) | (KeyCode::Char(c), KeyModifiers::SHIFT) => { self.command_palette.push_char(c); self.status = format!(":{}", self.command_palette.buffer()); } (KeyCode::Backspace, _) => { self.command_palette.pop_char(); self.status = format!(":{}", self.command_palette.buffer()); } _ => {} }, InputMode::ProviderSelection => match key.code { KeyCode::Esc => { self.set_input_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.set_input_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 => { if self.show_model_info { self.set_model_info_visible(false); self.status = "Closed model info panel".to_string(); } else { self.set_input_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::Scope { provider, .. } => { let provider_name = provider.clone(); self.expand_provider(&provider_name, false); self.status = format!("Expanded provider: {}", provider_name); self.error = None; } ModelSelectorItemKind::Model { .. } => { if let Some(model) = self.selected_model_info().cloned() { if self.apply_model_selection(model).await.is_err() { // apply_model_selection already sets status/error } } 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::Char('q') => { if self.show_model_info { self.set_model_info_visible(false); self.status = "Closed model info panel".to_string(); } else { self.set_input_mode(InputMode::Normal); } } KeyCode::Char('i') => { if let Some(model) = self.selected_model_info() { let model_id = model.id.clone(); if let Err(err) = self.ensure_model_details(&model_id, false).await { self.error = Some(format!("Failed to load model info: {}", err)); } } } KeyCode::Char('r') => { if let Some(model) = self.selected_model_info() { let model_id = model.id.clone(); if let Err(err) = self.ensure_model_details(&model_id, true).await { self.error = Some(format!("Failed to refresh model info: {}", err)); } else { self.error = None; } } } KeyCode::Char('j') => { if self.show_model_info && self.model_info_viewport_height > 0 { self.model_info_panel .scroll_down(self.model_info_viewport_height); } else { self.move_model_selection(1); } } KeyCode::Char('k') => { if self.show_model_info && self.model_info_viewport_height > 0 { self.model_info_panel.scroll_up(); } else { self.move_model_selection(-1); } } 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::Scope { provider, .. } => { 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; } } } KeyCode::Backspace => { self.pop_model_search_char(); } KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { self.clear_model_search_query(); } KeyCode::Char(c) if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT => { self.push_model_search_char(c); } _ => {} }, InputMode::Help => match key.code { KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') | KeyCode::F(1) => { self.set_input_mode(InputMode::Normal); self.help_tab_index = 0; // Reset to first tab self.reset_status(); } 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.set_input_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(); self.message_line_cache.clear(); self.chat_line_offset = 0; } Err(e) => { self.error = Some(format!("Failed to load session: {}", e)); } } } self.set_input_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.set_input_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.set_input_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) } fn handle_mouse_event(&mut self, mouse: MouseEvent) -> Result { if self.has_pending_consent() { return Ok(AppState::Running); } let region = self.region_for_position(mouse.column, mouse.row); match mouse.kind { MouseEventKind::ScrollUp => { if let Some(region) = region { self.handle_mouse_scroll(region, -MOUSE_SCROLL_STEP); } } MouseEventKind::ScrollDown => { if let Some(region) = region { self.handle_mouse_scroll(region, MOUSE_SCROLL_STEP); } } MouseEventKind::Down(MouseButton::Left) => { self.pending_key = None; if let Some(region) = region { self.handle_mouse_click(region, mouse.column, mouse.row); } } MouseEventKind::Drag(MouseButton::Left) => { if matches!(region, Some(UiRegion::Input)) { self.pending_key = None; self.handle_mouse_click(UiRegion::Input, mouse.column, mouse.row); } } _ => {} } Ok(AppState::Running) } fn handle_mouse_scroll(&mut self, region: UiRegion, amount: isize) { if amount == 0 { return; } match region { UiRegion::FileTree => { if self.focus_panel(FocusedPanel::Files) { self.file_tree_mut().move_cursor(amount); } } UiRegion::Thinking | UiRegion::Actions => { if self.focus_panel(FocusedPanel::Thinking) { let viewport = self.thinking_viewport_height.max(1); self.thinking_scroll.on_user_scroll(amount, viewport); } } UiRegion::Code => { if self.focus_panel(FocusedPanel::Code) { let viewport = self.code_view_viewport_height().max(1); if let Some(scroll) = self.code_view_scroll_mut() { scroll.on_user_scroll(amount, viewport); } } } UiRegion::ModelInfo => { self.scroll_model_info(amount); } UiRegion::Input => {} UiRegion::System | UiRegion::Status | UiRegion::Chat | UiRegion::Content | UiRegion::Frame | UiRegion::Header => { if self.focus_panel(FocusedPanel::Chat) { self.auto_scroll .on_user_scroll(amount, self.viewport_height); self.update_new_message_alert_after_scroll(); } } } } fn handle_mouse_click(&mut self, region: UiRegion, column: u16, row: u16) { match region { UiRegion::FileTree => { self.focus_panel(FocusedPanel::Files); self.set_input_mode(InputMode::Normal); } UiRegion::Thinking | UiRegion::Actions => { if self.focus_panel(FocusedPanel::Thinking) { self.set_input_mode(InputMode::Normal); } } UiRegion::Code => { if self.focus_panel(FocusedPanel::Code) { self.set_input_mode(InputMode::Normal); } } UiRegion::Input => { self.focus_panel(FocusedPanel::Input); self.set_input_mode(InputMode::Editing); if let Some(rect) = self.last_layout.input_panel { if let Some((line, column)) = self.input_cursor_from_point(rect, column, row) { let line = line.min(u16::MAX as usize) as u16; let column = column.min(u16::MAX as usize) as u16; self.textarea.move_cursor(CursorMove::Jump(line, column)); } } } UiRegion::ModelInfo => { self.set_input_mode(InputMode::Normal); } UiRegion::System | UiRegion::Status | UiRegion::Chat | UiRegion::Content | UiRegion::Frame | UiRegion::Header => { self.focus_panel(FocusedPanel::Chat); self.set_input_mode(InputMode::Normal); } } } fn input_cursor_from_point(&self, rect: Rect, column: u16, row: u16) -> Option<(usize, usize)> { let lines = self.textarea.lines(); if lines.is_empty() { return Some((0, 0)); } let inner_x = usize::from(column.saturating_sub(rect.x.saturating_add(1))); let inner_y = usize::from(row.saturating_sub(rect.y.saturating_add(1))); let max_line = lines.len().saturating_sub(1); let line_index = inner_y.min(max_line); let column_index = Self::grapheme_index_for_visual_offset(&lines[line_index], inner_x); Some((line_index, column_index)) } fn scroll_model_info(&mut self, amount: isize) { if amount == 0 { return; } let steps = amount.unsigned_abs(); let viewport = self.model_info_viewport_height.max(1); if amount.is_positive() { for _ in 0..steps { self.model_info_panel.scroll_down(viewport); } } else { for _ in 0..steps { self.model_info_panel.scroll_up(); } } } fn grapheme_index_for_visual_offset(line: &str, offset: usize) -> usize { let mut width = 0usize; for (idx, grapheme) in line.graphemes(true).enumerate() { let grapheme_width = UnicodeWidthStr::width(grapheme); if width + grapheme_width > offset { return idx; } width += grapheme_width; } line.graphemes(true).count() } /// 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); self.update_new_message_alert_after_scroll(); } 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::Files => { self.file_tree_mut().move_cursor(delta); } FocusedPanel::Code => { let viewport_height = self.code_view_viewport_height().max(1); if let Some(scroll) = self.code_view_scroll_mut() { 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); self.update_new_message_alert_after_scroll(); } FocusedPanel::Thinking => { let viewport_height = self.thinking_viewport_height.max(1); self.thinking_scroll.scroll_half_page_down(viewport_height); } FocusedPanel::Files => { self.file_tree_mut().page_down(); } FocusedPanel::Code => { let viewport_height = self.code_view_viewport_height().max(1); if let Some(scroll) = self.code_view_scroll_mut() { 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); self.update_new_message_alert_after_scroll(); } FocusedPanel::Thinking => { let viewport_height = self.thinking_viewport_height.max(1); self.thinking_scroll.scroll_half_page_up(viewport_height); } FocusedPanel::Files => { self.file_tree_mut().page_up(); } FocusedPanel::Code => { let viewport_height = self.code_view_viewport_height().max(1); if let Some(scroll) = self.code_view_scroll_mut() { 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); self.update_new_message_alert_after_scroll(); } FocusedPanel::Thinking => { let viewport_height = self.thinking_viewport_height.max(1); self.thinking_scroll.scroll_full_page_down(viewport_height); } FocusedPanel::Files => { self.file_tree_mut().page_down(); } FocusedPanel::Code => { let viewport_height = self.code_view_viewport_height().max(1); if let Some(scroll) = self.code_view_scroll_mut() { 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); self.update_new_message_alert_after_scroll(); } FocusedPanel::Thinking => { let viewport_height = self.thinking_viewport_height.max(1); self.thinking_scroll.scroll_full_page_up(viewport_height); } FocusedPanel::Files => { self.file_tree_mut().page_up(); } FocusedPanel::Code => { let viewport_height = self.code_view_viewport_height().max(1); if let Some(scroll) = self.code_view_scroll_mut() { 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(); self.chat_cursor = (0, 0); } FocusedPanel::Thinking => { self.thinking_scroll.jump_to_top(); } FocusedPanel::Files => { self.file_tree_mut().jump_to_top(); } FocusedPanel::Code => { if let Some(scroll) = self.code_view_scroll_mut() { 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); self.update_new_message_alert_after_scroll(); let rendered = self.get_rendered_lines(); if rendered.is_empty() { self.chat_cursor = (0, 0); } else { let last_index = rendered.len().saturating_sub(1); let last_col = rendered .last() .map(|line| line.chars().count()) .unwrap_or(0); self.chat_cursor = (last_index, last_col); } } FocusedPanel::Thinking => { let viewport_height = self.thinking_viewport_height.max(1); self.thinking_scroll.jump_to_bottom(viewport_height); } FocusedPanel::Files => { self.file_tree_mut().jump_to_bottom(); } FocusedPanel::Code => { let viewport_height = self.code_view_viewport_height().max(1); if let Some(scroll) = self.code_view_scroll_mut() { scroll.jump_to_bottom(viewport_height); } } FocusedPanel::Input => {} } } pub async fn handle_session_event(&mut self, event: SessionEvent) -> Result<()> { match event { SessionEvent::StreamChunk { message_id, response, } => { self.controller.apply_stream_chunk(message_id, &response)?; self.invalidate_message_cache(&message_id); // Update thinking content in real-time during streaming self.update_thinking_from_last_message(); self.notify_new_activity(); // Auto-scroll will handle this in the render loop if response.is_final { let recorded_snapshot = match response.usage.as_ref() { Some(usage) => { self.update_context_usage(usage); self.controller.record_usage_sample(usage).await } None => None, }; if let Some(snapshot) = recorded_snapshot { self.usage_snapshot = Some(snapshot.clone()); self.update_usage_toasts(&snapshot); } else { self.refresh_usage_summary().await?; } self.streaming.remove(&message_id); self.stream_tasks.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_id, message, } => { self.stop_loading_animation(); if let Some(id) = message_id { self.streaming.remove(&id); self.stream_tasks.remove(&id); self.invalidate_message_cache(&id); } else { self.streaming.clear(); self.stream_tasks.clear(); self.message_line_cache.clear(); } 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::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.notify_new_activity(); 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(); } SessionEvent::OAuthPoll { server, authorization, } => { match self .controller .poll_oauth_device_flow(&server, &authorization) .await { Ok(DevicePollState::Pending { retry_in }) => { self.oauth_flows .insert(server.clone(), authorization.clone()); let server_name = server.clone(); self.schedule_oauth_poll(server, authorization, retry_in); self.status = format!("Waiting for OAuth approval for {server_name}..."); } Ok(DevicePollState::Complete(_token)) => { self.oauth_flows.remove(&server); self.push_toast( ToastLevel::Success, format!("OAuth authorization complete for {server}."), ); self.status = format!("OAuth authorization complete for {server}."); if let Err(err) = self.refresh_resource_catalog().await { self.push_toast( ToastLevel::Error, format!("Failed to refresh MCP resources: {err}"), ); } if let Err(err) = self.refresh_mcp_slash_commands().await { self.push_toast( ToastLevel::Error, format!("Failed to refresh MCP slash commands: {err}"), ); } } Err(err) => { self.oauth_flows.remove(&server); self.error = Some(format!("OAuth flow for '{server}' failed: {err}")); self.push_toast( ToastLevel::Error, format!("OAuth failure for {server}: {err}"), ); } } } } Ok(()) } fn reset_status(&mut self) { self.status = "Normal mode • Press F1 for help".to_string(); self.error = None; } async fn refresh_usage_summary(&mut self) -> Result<()> { if let Some(snapshot) = self.controller.current_usage_snapshot().await { self.usage_snapshot = Some(snapshot.clone()); self.update_usage_toasts(&snapshot); } else { self.usage_snapshot = None; } Ok(()) } fn update_usage_toasts(&mut self, snapshot: &UsageSnapshot) { for window in [UsageWindow::Hour, UsageWindow::Week] { let key = (snapshot.provider.clone(), window); let metrics = snapshot.window(window); let quota = match metrics.quota_tokens { Some(value) if value > 0 => value, _ => { self.usage_thresholds.remove(&key); continue; } }; let previous = self .usage_thresholds .get(&key) .copied() .unwrap_or(UsageBand::Normal); let current = metrics.band(); if current > previous { if let Some(percent_ratio) = metrics.percent_of_quota() { let percent_value = percent_ratio * 100.0; let percent_text = Self::format_percent_value(percent_value.min(999.9)); let quota_text = format_token_short(quota); let used_text = format_token_short(metrics.total_tokens); let provider_display = Self::provider_display_name(&snapshot.provider); let window_label = Self::usage_window_label(window); let message = format!( "{} {} usage at {}% ({}/{})", provider_display, window_label, percent_text, used_text, quota_text ); let level = if current == UsageBand::Critical { ToastLevel::Error } else { ToastLevel::Warning }; self.push_toast(level, message); } } else if current == UsageBand::Normal && previous != UsageBand::Normal { self.usage_thresholds.insert(key.clone(), UsageBand::Normal); } self.usage_thresholds.insert(key, current); } } async fn show_usage_limits(&mut self) -> Result<()> { let snapshots = self.controller.usage_overview().await; if snapshots.is_empty() { let message = "Usage: no data recorded yet.".to_string(); self.status = message.clone(); self.error = None; self.push_toast(ToastLevel::Info, message); return Ok(()); } let mut parts = Vec::new(); let mut current_snapshot: Option = None; for snapshot in snapshots.iter() { if snapshot.provider == self.current_provider { current_snapshot = Some(snapshot.clone()); } self.update_usage_toasts(snapshot); let provider_display = Self::provider_display_name(&snapshot.provider); let hour = Self::summarize_usage_window("hour", snapshot.window(UsageWindow::Hour)); let week = Self::summarize_usage_window("week", snapshot.window(UsageWindow::Week)); parts.push(format!("{provider_display}: {hour}; {week}")); } if let Some(snapshot) = current_snapshot { self.usage_snapshot = Some(snapshot); } let message = parts.join(" | "); self.status = format!("Usage • {message}"); self.error = None; self.push_toast(ToastLevel::Info, message.clone()); Ok(()) } fn summarize_usage_window(label: &str, metrics: &WindowMetrics) -> String { let used = format_token_short(metrics.total_tokens); if let Some(quota) = metrics.quota_tokens { if quota == 0 { return format!("{label} {used} tokens"); } let quota_text = format_token_short(quota); let percent = metrics .percent_of_quota() .map(|ratio| ratio * 100.0) .unwrap_or(0.0); let percent_text = Self::format_percent_value(percent.min(999.9)); format!("{label} {used}/{quota_text} ({percent_text}%)") } else { format!("{label} {used} tokens") } } fn usage_window_label(window: UsageWindow) -> &'static str { match window { UsageWindow::Hour => "hourly", UsageWindow::Week => "weekly", } } fn format_percent_value(percent: f64) -> String { if percent >= 100.0 || percent == 0.0 { format!("{percent:.0}") } else if percent >= 10.0 { format!("{percent:.0}") } else { format!("{percent:.1}") } } async fn collect_models_from_all_providers( &self, ) -> ( Vec, Vec, HashMap, ) { 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(); let mut scope_status_map: HashMap = HashMap::new(); let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../..") .canonicalize() .ok(); let server_binary = workspace_root.as_ref().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()) .map(|p| p.to_string_lossy().into_owned()) }); 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; } if !provider_cfg.enabled { continue; } let canonical_name = Self::canonical_provider_id(&name); // All providers communicate via MCP LLM server (Phase 10). // Select provider by name via OWLEN_PROVIDER so per-provider settings apply. let mut env_vars = HashMap::new(); env_vars.insert("OWLEN_PROVIDER".to_string(), canonical_name.clone()); let client_result = if let Some(binary_path) = server_binary.as_ref() { use owlen_core::config::McpServerConfig; let config = McpServerConfig { name: format!("provider::{canonical_name}"), command: binary_path.clone(), args: Vec::new(), transport: "stdio".to_string(), env: env_vars.clone(), oauth: None, }; RemoteMcpClient::new_with_config(&config) } else { // Fallback to legacy discovery: temporarily set env vars while spawning. Self::with_temp_env_vars(&env_vars, RemoteMcpClient::new) }; match client_result { Ok(client) => { let client: Arc = Arc::new(client); match client.list_models().await { Ok(mut provider_models) => { for model in &mut provider_models { model.provider = canonical_name.clone(); } let statuses = Self::extract_scope_status(&provider_models); Self::accumulate_scope_errors(&mut errors, &canonical_name, &statuses); scope_status_map.insert(canonical_name.clone(), statuses); models.extend(provider_models); } Err(err) => { scope_status_map .insert(canonical_name.clone(), ProviderScopeStatus::default()); errors.push(format!("{}: {}", name, err)) } } } Err(err) => { scope_status_map.insert(canonical_name.clone(), ProviderScopeStatus::default()); errors.push(format!("{}: {}", canonical_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, scope_status_map) } fn scope_from_keyword(value: &str) -> ModelScope { match value { "local" => ModelScope::Local, "cloud" => ModelScope::Cloud, other => ModelScope::Other(other.to_string()), } } fn extract_scope_status(models: &[ModelInfo]) -> ProviderScopeStatus { let mut statuses: ProviderScopeStatus = BTreeMap::new(); for model in models { for capability in &model.capabilities { if let Some(rest) = capability.strip_prefix("scope-status:") { let mut parts = rest.split(':'); let scope_key = parts.next().unwrap_or_default().to_ascii_lowercase(); let state_key = parts.next().unwrap_or_default().to_ascii_lowercase(); let scope = Self::scope_from_keyword(&scope_key); let state = match state_key.as_str() { "available" => ModelAvailabilityState::Available, "unavailable" => ModelAvailabilityState::Unavailable, _ => ModelAvailabilityState::Unknown, }; let entry = statuses.entry(scope).or_default(); if state > entry.state || entry.state == ModelAvailabilityState::Unknown { entry.state = state; } } else if let Some(rest) = capability.strip_prefix("scope-status-message:") { let mut parts = rest.split(':'); let scope_key = parts.next().unwrap_or_default().to_ascii_lowercase(); let message = parts.collect::>().join(":"); let scope = Self::scope_from_keyword(&scope_key); let entry = statuses.entry(scope).or_default(); if entry.message.is_none() && !message.trim().is_empty() { entry.message = Some(message.trim().to_string()); } } else if let Some(rest) = capability.strip_prefix("scope-status-age:") { let mut parts = rest.split(':'); let scope_key = parts.next().unwrap_or_default().to_ascii_lowercase(); let value = parts.next().unwrap_or_default(); if let Ok(age) = value.parse::() { let scope = Self::scope_from_keyword(&scope_key); let entry = statuses.entry(scope).or_default(); entry.last_checked_secs = Some(age); } } else if let Some(rest) = capability.strip_prefix("scope-status-success-age:") { let mut parts = rest.split(':'); let scope_key = parts.next().unwrap_or_default().to_ascii_lowercase(); let value = parts.next().unwrap_or_default(); if let Ok(age) = value.parse::() { let scope = Self::scope_from_keyword(&scope_key); let entry = statuses.entry(scope).or_default(); entry.last_success_secs = Some(age); } } else if let Some(rest) = capability.strip_prefix("scope-status-stale:") { let mut parts = rest.split(':'); let scope_key = parts.next().unwrap_or_default().to_ascii_lowercase(); let value = parts.next().unwrap_or_default(); let scope = Self::scope_from_keyword(&scope_key); let entry = statuses.entry(scope).or_default(); entry.is_stale = matches!(value, "1" | "true" | "True" | "TRUE"); } } } statuses } fn accumulate_scope_errors( errors: &mut Vec, provider: &str, statuses: &ProviderScopeStatus, ) { for (scope, entry) in statuses { if entry.state == ModelAvailabilityState::Unavailable { let scope_name = Self::scope_display_name(scope); if let Some(summary) = Self::scope_status_summary(entry) { errors.push(format!("{provider}: {scope_name} {summary}")); } else { errors.push(format!("{provider}: {scope_name} unavailable")); } } else if entry.state == ModelAvailabilityState::Available && entry.is_stale { let scope_name = Self::scope_display_name(scope); let summary = Self::scope_status_summary(entry) .unwrap_or_else(|| "using cached results".to_string()); errors.push(format!("{provider}: {scope_name} degraded ({summary})")); } } } pub(crate) fn model_scope_from_capabilities(model: &ModelInfo) -> ModelScope { for capability in &model.capabilities { if let Some(tag) = capability.strip_prefix("scope:") { return match tag { "local" => ModelScope::Local, "cloud" => ModelScope::Cloud, other => ModelScope::Other(other.to_string()), }; } } ModelScope::Other("unknown".to_string()) } pub(crate) fn scope_icon(scope: &ModelScope) -> &'static str { match scope { ModelScope::Local => "🖥️", ModelScope::Cloud => "☁", ModelScope::Other(_) => "◇", } } pub(crate) fn scope_display_name(scope: &ModelScope) -> String { match scope { ModelScope::Local => "Local".to_string(), ModelScope::Cloud => "Cloud".to_string(), ModelScope::Other(other) => capitalize_first(other), } } fn format_duration_short(seconds: u64) -> String { const MINUTE: u64 = 60; const HOUR: u64 = 60 * MINUTE; const DAY: u64 = 24 * HOUR; if seconds < MINUTE { format!("{seconds}s") } else if seconds < HOUR { format!("{}m", seconds / MINUTE) } else if seconds < DAY { let hours = seconds / HOUR; let minutes = (seconds % HOUR) / MINUTE; if minutes == 0 { format!("{hours}h") } else { format!("{hours}h{minutes}m") } } else { format!("{}d", seconds / DAY) } } fn scope_status_summary(status: &ScopeStatusEntry) -> Option { let mut segments: Vec = Vec::new(); if let Some(message) = status.message.as_ref() { if !message.is_empty() { segments.push(message.clone()); } } else if status.state == ModelAvailabilityState::Unavailable { segments.push("Unavailable".to_string()); } else if status.state == ModelAvailabilityState::Available && status.is_stale { segments.push("Using cached results".to_string()); } if let Some(age) = status.last_checked_secs { segments.push(format!("checked {} ago", Self::format_duration_short(age))); } if let Some(success_age) = status.last_success_secs { if status.state == ModelAvailabilityState::Unavailable { segments.push(format!( "last ok {} ago", Self::format_duration_short(success_age) )); } else if status.state == ModelAvailabilityState::Available && status.is_stale { segments.push(format!( "last refresh {} ago", Self::format_duration_short(success_age) )); } } if segments.is_empty() { None } else { Some(segments.join(" · ")) } } fn scope_header_label( scope: &ModelScope, status: &ScopeStatusEntry, filter: FilterMode, ) -> String { let icon = Self::scope_icon(scope); let scope_name = Self::scope_display_name(scope); let mut label = format!("{icon} {scope_name}"); match status.state { ModelAvailabilityState::Available => { label.push_str(" · ✓"); if status.is_stale { label.push_str(" · ⚠"); } } ModelAvailabilityState::Unavailable => { if status.last_success_secs.is_some() { label.push_str(" · ⚠"); } else { label.push_str(" · ✗"); } } ModelAvailabilityState::Unknown => label.push_str(" · ⚙"), } if let Some(age) = status.last_checked_secs { label.push_str(&format!(" · {}", Self::format_duration_short(age))); } if matches!(filter, FilterMode::Available) { label.push_str(" · available only"); } label } fn deduplicate_models_for_scope<'a>( entries: Vec<(usize, &'a ModelInfo)>, provider_lower: &str, scope: &ModelScope, ) -> Vec<(usize, &'a ModelInfo)> { let mut best_by_canonical: HashMap = HashMap::new(); for (idx, model) in entries { let canonical = model.id.to_string(); let is_cloud_id = model.id.ends_with("-cloud"); let priority = if matches!( provider_lower, "ollama" | "ollama_local" | "ollama-cloud" | "ollama_cloud" ) { match scope { ModelScope::Local => { if is_cloud_id { 1 } else { 2 } } ModelScope::Cloud => { if is_cloud_id { 2 } else { 1 } } ModelScope::Other(_) => 1, } } else { 1 }; best_by_canonical .entry(canonical) .and_modify(|entry| { if priority > entry.0 || (priority == entry.0 && model.id < entry.1.1.id) { *entry = (priority, (idx, model)); } }) .or_insert((priority, (idx, model))); } let mut matches: Vec<(usize, &'a ModelInfo)> = best_by_canonical .into_values() .map(|entry| entry.1) .collect(); matches.sort_by(|(_, a), (_, b)| a.id.cmp(&b.id)); matches } fn recompute_available_providers(&mut self) { let mut providers: BTreeSet = self .controller .config() .providers .iter() .filter(|(_, cfg)| cfg.enabled) .map(|(name, _)| Self::canonical_provider_id(name)) .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_local".to_string()); } self.available_providers = providers.into_iter().collect(); } fn canonical_provider_id(provider: &str) -> String { let normalized = provider.trim().to_ascii_lowercase(); if normalized.is_empty() { return "ollama_local".to_string(); } match normalized.replace('-', "_").as_str() { "ollama" => "ollama_local".to_string(), "ollama_local" => "ollama_local".to_string(), "ollama_cloud" => "ollama_cloud".to_string(), other => other.to_string(), } } fn with_temp_env_vars(env_vars: &HashMap, action: F) -> T where F: FnOnce() -> T, { let backups: Vec<(String, Option)> = env_vars .keys() .map(|key| (key.clone(), std::env::var(key).ok())) .collect(); for (key, value) in env_vars { // Safety: environment mutations are scoped to this synchronous call and restored // immediately afterwards, so no other threads observe inconsistent state. unsafe { std::env::set_var(key, value); } } let result = action(); for (key, original) in backups { unsafe { if let Some(value) = original { std::env::set_var(&key, value); } else { std::env::remove_var(&key); } } } result } fn rebuild_annotated_models(&mut self) { let mut annotated = Vec::with_capacity(self.models.len()); for model in &self.models { let provider_id = model.provider.clone(); let scope = Self::model_scope_from_capabilities(model); let scope_state = self.provider_scope_state(provider_id.as_str(), &scope); let provider_status = Self::provider_status_from_state(scope_state); let provider_type = Self::infer_provider_type(&provider_id, &scope); let mut provider_metadata = ProviderMetadata::new( provider_id.clone(), Self::provider_display_name(&provider_id), provider_type, matches!(provider_type, ProviderType::Cloud), ); provider_metadata.metadata.insert( "scope".to_string(), Value::String(Self::scope_display_name(&scope)), ); provider_metadata.metadata.insert( "provider_tag".to_string(), Value::String(Self::provider_tag(&provider_id)), ); let mut model_metadata = HashMap::new(); model_metadata.insert( "display_name".to_string(), Value::String(Self::display_name_for_model(model)), ); if let Some(ctx) = model.context_window { model_metadata.insert("context_window".to_string(), Value::from(ctx)); } model_metadata.insert( "provider_tag".to_string(), Value::String(Self::provider_tag(&provider_id)), ); let provider_model = ProviderModelInfo { name: model.id.clone(), size_bytes: None, capabilities: model.capabilities.clone(), description: model.description.clone(), provider: provider_metadata, metadata: model_metadata, }; annotated.push(AnnotatedModelInfo { provider_id, provider_status, model: provider_model, }); } self.annotated_models = annotated; } fn rebuild_model_selector_items(&mut self) { let mut items = Vec::new(); self.model_search_hits.clear(); self.provider_search_hits.clear(); self.visible_model_count = 0; if self.available_providers.is_empty() { items.push(ModelSelectorItem::header( "ollama_local", false, ProviderStatus::RequiresSetup, ProviderType::Local, )); self.model_selector_items = items; return; } let search_query = self.model_search_query.trim().to_string(); let search_active = !search_query.is_empty(); let force_expand = search_active; let expanded = self.expanded_provider.clone(); for provider in &self.available_providers { let provider_lower = provider.to_ascii_lowercase(); let provider_display = Self::provider_display_name(provider); let provider_status = self.provider_overall_status(provider); let provider_type = self.provider_type_for(provider); let provider_highlight = if search_active { search_candidate(provider_display.as_str(), &search_query).map(|(_, mask)| mask) } else { None }; if let Some(mask) = provider_highlight.clone() { self.provider_search_hits.insert(provider.clone(), mask); } let is_expanded = force_expand || expanded.as_ref().map(|p| p == provider).unwrap_or(false); let mut provider_block = Vec::new(); provider_block.push(ModelSelectorItem::header( provider.clone(), is_expanded, provider_status, provider_type, )); if !is_expanded { items.extend(provider_block); continue; } let status_map = self.provider_scope_status.get(provider); let mut scoped: BTreeMap> = BTreeMap::new(); for (idx, model) in self.models.iter().enumerate() { if &model.provider == provider { let scope = Self::model_scope_from_capabilities(model); scoped.entry(scope).or_default().push((idx, model)); } } let mut scopes_to_render: BTreeSet = BTreeSet::new(); scopes_to_render.extend(scoped.keys().cloned()); if let Some(statuses) = status_map { scopes_to_render.extend(statuses.keys().cloned()); } let mut rendered_scope = false; let mut rendered_body = false; let mut provider_has_models = false; for scope in scopes_to_render { if !self.filter_allows_scope(&scope) { continue; } let entries = scoped.get(&scope).cloned().unwrap_or_default(); let deduped = Self::deduplicate_models_for_scope(entries, &provider_lower, &scope); let status_entry = status_map .and_then(|map| map.get(&scope)) .cloned() .unwrap_or_default(); let mut filtered: Vec<(usize, &ModelInfo)> = Vec::new(); for (idx, model) in deduped { let search_info = if search_active { self.evaluate_model_search(provider, model, &search_query) } else { None }; if let Some(info) = search_info { self.model_search_hits.insert(idx, info); filtered.push((idx, model)); } else if !search_active { filtered.push((idx, model)); } } if search_active && filtered.is_empty() { continue; } rendered_scope = true; let label = Self::scope_header_label(&scope, &status_entry, self.model_filter_mode); provider_block.push(ModelSelectorItem::scope( provider.clone(), label, scope.clone(), status_entry.state, )); if status_entry.state != ModelAvailabilityState::Available || status_entry.is_stale || status_entry.message.is_some() { if let Some(summary) = Self::scope_status_summary(&status_entry) { provider_block.push(ModelSelectorItem::empty( provider.clone(), Some(summary), Some(status_entry.state), )); rendered_body = true; } } let scope_allowed = self.filter_scope_allows_models(&scope, &status_entry); if filtered.is_empty() { if !scope_allowed { if let Some(msg) = self.scope_filter_message(&scope, &status_entry) { provider_block.push(ModelSelectorItem::empty( provider.clone(), Some(msg), Some(status_entry.state), )); rendered_body = true; } } else if !search_active { let message = match status_entry.state { ModelAvailabilityState::Unavailable => { format!("{} unavailable", Self::scope_display_name(&scope)) } ModelAvailabilityState::Available => { format!("No {} models found", Self::scope_display_name(&scope)) } ModelAvailabilityState::Unknown => "No models configured".to_string(), }; provider_block.push(ModelSelectorItem::empty( provider.clone(), Some(message), Some(status_entry.state), )); rendered_body = true; } continue; } if !scope_allowed { if let Some(msg) = self.scope_filter_message(&scope, &status_entry) { provider_block.push(ModelSelectorItem::empty( provider.clone(), Some(msg), Some(status_entry.state), )); rendered_body = true; } continue; } rendered_body = true; provider_has_models = true; for (idx, _) in filtered { provider_block.push(ModelSelectorItem::model(provider.clone(), idx)); } } if !provider_has_models && search_active && provider_highlight.is_some() { provider_block.push(ModelSelectorItem::empty( provider.clone(), Some(format!( "Provider matches '{}' but no models found", search_query )), None, )); rendered_body = true; } if !rendered_scope && !rendered_body { if !search_active { provider_block.push(ModelSelectorItem::empty(provider.clone(), None, None)); } else if provider_highlight.is_some() { provider_block.push(ModelSelectorItem::empty( provider.clone(), Some(format!("No models matching '{}'", search_query)), None, )); } else { continue; } } items.extend(provider_block); } if items.is_empty() { items.push(ModelSelectorItem::empty( "providers", Some(if search_active { format!("No models matching '{}'", search_query) } else { "No providers configured".to_string() }), None, )); } self.visible_model_count = items .iter() .filter(|item| matches!(item.kind(), ModelSelectorItemKind::Model { .. })) .count(); self.model_selector_items = items; self.ensure_valid_model_selection(); if search_active { let current_is_model = self .current_model_selector_item() .map(|item| matches!(item.kind(), ModelSelectorItemKind::Model { .. })) .unwrap_or(false); if !current_is_model { if let Some((idx, _)) = self .model_selector_items .iter() .enumerate() .find(|(_, item)| matches!(item.kind(), ModelSelectorItemKind::Model { .. })) { self.set_selected_model_item(idx); } } } } fn evaluate_model_search( &self, provider: &str, model: &ModelInfo, query: &str, ) -> Option { let mut info = ModelSearchInfo::default(); let mut best: Option<(usize, usize)> = None; let mut consider = |candidate: Option<&str>, target: &mut Option| { if let Some(text) = candidate { if let Some((score, mask)) = search_candidate(text, query) { let replace = best.is_none_or(|current| score < current); if replace { best = Some(score); } *target = Some(mask); } } }; let display_name = Self::display_name_for_model(model); consider(Some(display_name.as_str()), &mut info.name); consider(Some(model.id.as_str()), &mut info.id); let provider_display = Self::provider_display_name(provider); consider(Some(provider_display.as_str()), &mut info.provider); if let Some(desc) = model.description.as_deref() { consider(Some(desc), &mut info.description); } if let Some(score) = best { info.score = score; Some(info) } else { None } } fn provider_scope_state(&self, provider: &str, scope: &ModelScope) -> ModelAvailabilityState { self.provider_scope_status .get(provider) .and_then(|map| map.get(scope)) .map(|entry| entry.state) .unwrap_or(ModelAvailabilityState::Unknown) } fn provider_overall_status(&self, provider: &str) -> ProviderStatus { if let Some(status_map) = self.provider_scope_status.get(provider) { let mut saw_unknown = false; for entry in status_map.values() { match entry.state { ModelAvailabilityState::Unavailable => return ProviderStatus::Unavailable, ModelAvailabilityState::Unknown => saw_unknown = true, ModelAvailabilityState::Available => { if entry.is_stale { saw_unknown = true; } } } } if saw_unknown { ProviderStatus::RequiresSetup } else { ProviderStatus::Available } } else { self.annotated_models .iter() .find(|m| m.provider_id == provider) .map(|m| m.provider_status) .unwrap_or(ProviderStatus::RequiresSetup) } } fn provider_type_for(&self, provider: &str) -> ProviderType { self.annotated_models .iter() .find(|m| m.provider_id == provider) .map(|m| m.model.provider.provider_type) .unwrap_or_else(|| { if provider.to_ascii_lowercase().contains("cloud") { ProviderType::Cloud } else { ProviderType::Local } }) } fn filter_allows_scope(&self, scope: &ModelScope) -> bool { match self.model_filter_mode { FilterMode::All => true, FilterMode::LocalOnly => matches!(scope, ModelScope::Local), FilterMode::CloudOnly => matches!(scope, ModelScope::Cloud), FilterMode::Available => true, } } fn filter_scope_allows_models(&self, scope: &ModelScope, status: &ScopeStatusEntry) -> bool { match self.model_filter_mode { FilterMode::Available => { status.state == ModelAvailabilityState::Available && !status.is_stale } FilterMode::LocalOnly => matches!(scope, ModelScope::Local), FilterMode::CloudOnly => matches!(scope, ModelScope::Cloud), FilterMode::All => true, } } fn scope_filter_message( &self, scope: &ModelScope, status: &ScopeStatusEntry, ) -> Option { match self.model_filter_mode { FilterMode::Available => { if status.state == ModelAvailabilityState::Available && !status.is_stale { return None; } Self::scope_status_summary(status).or_else(|| match status.state { ModelAvailabilityState::Unavailable => { Some(format!("{} unavailable", Self::scope_display_name(scope))) } ModelAvailabilityState::Unknown => Some(format!( "{} setup required", Self::scope_display_name(scope) )), ModelAvailabilityState::Available => Some(format!( "{} cached results", Self::scope_display_name(scope) )), }) } FilterMode::LocalOnly | FilterMode::CloudOnly => { if status.state == ModelAvailabilityState::Unavailable { Self::scope_status_summary(status).or_else(|| { Some(format!("{} unavailable", Self::scope_display_name(scope))) }) } else { None } } FilterMode::All => None, } } pub(crate) fn provider_display_name(provider: &str) -> String { if provider.trim().is_empty() { return "Provider".to_string(); } let normalized = provider.replace(['_', '-'], " "); capitalize_first(normalized.as_str()) } fn provider_tag(provider: &str) -> String { match provider.trim().to_ascii_lowercase().as_str() { "ollama" | "ollama_local" => "ollama".to_string(), "ollama-cloud" | "ollama_cloud" => "ollama-cloud".to_string(), other => other.to_string(), } } fn infer_provider_type(provider: &str, scope: &ModelScope) -> ProviderType { match scope { ModelScope::Local => ProviderType::Local, ModelScope::Cloud => ProviderType::Cloud, ModelScope::Other(_) => { if provider.to_ascii_lowercase().contains("cloud") { ProviderType::Cloud } else { ProviderType::Local } } } } fn provider_status_from_state(state: ModelAvailabilityState) -> ProviderStatus { match state { ModelAvailabilityState::Available => ProviderStatus::Available, ModelAvailabilityState::Unavailable => ProviderStatus::Unavailable, ModelAvailabilityState::Unknown => ProviderStatus::RequiresSetup, } } 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: 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::Scope { provider, .. } | ModelSelectorItemKind::Model { provider, .. } | ModelSelectorItemKind::Empty { provider, .. } => { self.selected_provider = provider.clone(); self.update_selected_provider_index(); } } } } fn focus_first_model_in_scope(&mut self, scope: &ModelScope) -> bool { if self.model_selector_items.is_empty() { return false; } let scope_index = self .model_selector_items .iter() .enumerate() .find(|(_, item)| matches!(item.kind(), ModelSelectorItemKind::Scope { scope: s, .. } if s == scope)) .map(|(idx, _)| idx); let Some(scope_idx) = scope_index else { return false; }; self.set_selected_model_item(scope_idx); let len = self.model_selector_items.len(); let mut cursor = scope_idx + 1; while cursor < len { match self.model_selector_items[cursor].kind() { ModelSelectorItemKind::Model { .. } => { self.set_selected_model_item(cursor); return true; } ModelSelectorItemKind::Scope { .. } | ModelSelectorItemKind::Header { .. } => break, _ => {} } cursor += 1; } true } fn focus_first_available_model(&mut self) -> bool { if self.model_selector_items.is_empty() { return false; } if let Some(idx) = self.first_model_item_index() { self.set_selected_model_item(idx); true } else { false } } 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(); } else { self.selected_provider_index = 0; } } 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<()> { let canonical_name = Self::canonical_provider_id(provider_name); if Self::canonical_provider_id(&self.current_provider) == canonical_name { return Ok(()); } use owlen_core::config::McpServerConfig; use std::collections::HashMap; if self.controller.config().provider(&canonical_name).is_none() { let mut guard = self.controller.config_mut(); config::ensure_provider_config(&mut guard, &canonical_name); } let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../..") .canonicalize() .ok(); let server_binary = workspace_root.as_ref().and_then(|root| { [ "target/debug/owlen-mcp-llm-server", "target/release/owlen-mcp-llm-server", ] .iter() .map(|rel| root.join(rel)) .find(|p| p.exists()) }); let mut env_vars = HashMap::new(); env_vars.insert("OWLEN_PROVIDER".to_string(), canonical_name.clone()); let provider: Arc = if let Some(path) = server_binary { let config = McpServerConfig { name: canonical_name.clone(), command: path.to_string_lossy().into_owned(), args: Vec::new(), transport: "stdio".to_string(), env: env_vars, oauth: None, }; Arc::new(RemoteMcpClient::new_with_config(&config)?) } else { Arc::new(Self::with_temp_env_vars(&env_vars, RemoteMcpClient::new)?) }; self.controller.switch_provider(provider).await?; self.current_provider = canonical_name; self.model_details_cache.clear(); self.model_info_panel.clear(); self.set_model_info_visible(false); self.update_command_palette_catalog(); Ok(()) } async fn populate_model_details_cache_from_session(&mut self) { self.model_details_cache.clear(); let mut populated = false; if let Ok(details) = self.controller.all_model_details(false).await { for info in details { self.model_details_cache.insert(info.name.clone(), info); } populated = !self.model_details_cache.is_empty(); } if !populated { let cached = self.controller.cached_model_details().await; for info in cached { self.model_details_cache.insert(info.name.clone(), info); } } } 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, scope_status) = 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.provider_scope_status.clear(); self.model_details_cache.clear(); self.model_info_panel.clear(); self.set_model_info_visible(false); self.recompute_available_providers(); if self.available_providers.is_empty() { self.available_providers.push("ollama_local".to_string()); } self.rebuild_model_selector_items(); self.selected_model_item = None; self.status = "No models available".to_string(); self.update_selected_provider_index(); self.update_command_palette_catalog(); return Ok(()); } self.models = all_models; self.provider_scope_status = scope_status; self.rebuild_annotated_models(); self.model_info_panel.clear(); self.set_model_info_visible(false); self.populate_model_details_cache_from_session().await; self.recompute_available_providers(); if self.available_providers.is_empty() { self.available_providers.push("ollama_local".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() ); self.rebuild_model_selector_items(); self.update_command_palette_catalog(); Ok(()) } async fn apply_model_selection(&mut self, model: ModelInfo) -> Result<()> { let model_id = model.id.clone(); let model_label = Self::display_name_for_model(&model); 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 Err(err); } self.selected_provider = model.provider.clone(); self.update_selected_provider_index(); self.controller.set_model(model_id.clone()).await; self.status = format!( "Using model: {} (provider: {})", model_label, self.selected_provider ); 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.set_input_mode(InputMode::Normal); self.set_model_info_visible(false); Ok(()) } async fn apply_provider_mode(&mut self, provider: &str, mode: &str) -> Result<()> { let normalized = match mode.trim().to_ascii_lowercase().as_str() { "local" | "cloud" | "auto" => mode.trim().to_ascii_lowercase(), other => { return Err(anyhow!( "Unknown provider mode '{other}'. Expected local, cloud, or auto" )); } }; { let mut config = self.controller.config_mut(); config::ensure_provider_config(&mut config, provider); } { let mut config = self.controller.config_mut(); if let Some(entry) = config.providers.get_mut(provider) { entry.extra.insert( OLLAMA_MODE_KEY.to_string(), serde_json::Value::String(normalized.clone()), ); } else { return Err(anyhow!("Provider '{provider}' is not configured")); } } if let Err(err) = config::save_config(&self.controller.config()) { self.error = Some(format!("Failed to save provider mode: {err}")); return Err(err); } if provider.eq_ignore_ascii_case(&self.selected_provider) { if let Err(err) = self.refresh_models().await { self.error = Some(format!( "Provider mode updated but refreshing models failed: {}", err )); return Err(err); } } self.error = None; self.status = format!( "Provider {} mode set to {}", provider, normalized.to_ascii_uppercase() ); Ok(()) } async fn handle_cloud_command(&mut self, args: &[&str]) -> Result<()> { if args.is_empty() { return Err(anyhow!( "Usage: :cloud [options]" )); } match args[0] { "setup" => self.cloud_setup(&args[1..]).await, "status" | "models" | "logout" => Err(anyhow!( ":cloud {} is not implemented in the TUI. Run `owlen cloud {}` from the shell.", args[0], args[0] )), other => Err(anyhow!("Unknown :cloud subcommand: {other}")), } } async fn cloud_setup(&mut self, args: &[&str]) -> Result<()> { let options = CloudSetupOptions::parse(args)?; let mut stored_securely = false; let (existing_plain_api_key, normalized_endpoint, encryption_enabled, base_was_overridden) = { let mut config = self.controller.config_mut(); config::ensure_provider_config(&mut config, &options.provider); let (existing_plain_api_key, normalized_endpoint_local, base_overridden_local) = if let Some(entry) = config.providers.get_mut(&options.provider) { let existing = entry.api_key.clone(); entry.enabled = true; entry.provider_type = "ollama_cloud".to_string(); let should_update_env = match entry.api_key_env.as_deref() { None => true, Some(value) => { value.eq_ignore_ascii_case(LEGACY_OLLAMA_CLOUD_API_KEY_ENV) || value.eq_ignore_ascii_case(LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV) } }; if should_update_env { entry.api_key_env = Some(OLLAMA_API_KEY_ENV.to_string()); } let requested = options .endpoint .clone() .unwrap_or_else(|| DEFAULT_CLOUD_ENDPOINT.to_string()); let normalized_endpoint_local = normalize_cloud_endpoint(&requested); entry.extra.insert( OLLAMA_CLOUD_ENDPOINT_KEY.to_string(), Value::String(normalized_endpoint_local.clone()), ); let should_override = options.force_cloud_base_url || entry .base_url .as_ref() .map(|value| value.trim().is_empty()) .unwrap_or(true); let mut base_overridden_local = false; if should_override { entry.base_url = Some(normalized_endpoint_local.clone()); base_overridden_local = true; } (existing, normalized_endpoint_local, base_overridden_local) } else { return Err(anyhow!("Provider '{}' is not configured", options.provider)); }; let encryption_enabled = config.privacy.encrypt_local_data; ( existing_plain_api_key, normalized_endpoint_local, encryption_enabled, base_overridden_local, ) }; let base_overridden = base_was_overridden; let credential_manager = self.controller.credential_manager(); let mut resolved_api_key = options .api_key .clone() .filter(|value| !value.trim().is_empty()); if resolved_api_key.is_none() { if let Some(existing) = existing_plain_api_key.as_ref() { if !existing.trim().is_empty() { resolved_api_key = Some(existing.clone()); } } } if resolved_api_key.is_none() && credential_manager.is_some() { if let Some(manager) = credential_manager.clone() { if let Some(credentials) = manager .get_credentials(OLLAMA_CLOUD_CREDENTIAL_ID) .await .with_context(|| "Failed to load stored Ollama Cloud credentials")? { if !credentials.api_key.trim().is_empty() { resolved_api_key = Some(credentials.api_key); } } } } if resolved_api_key.is_none() { if let Ok(env_key) = std::env::var("OLLAMA_API_KEY") { if !env_key.trim().is_empty() { resolved_api_key = Some(env_key); } } } if resolved_api_key.is_none() { return Err(anyhow!( "No API key provided. Pass `--api-key ` or export OLLAMA_API_KEY." )); } let api_key = resolved_api_key .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .ok_or_else(|| anyhow!("Ollama Cloud API key cannot be blank"))?; if encryption_enabled { if let Some(manager) = credential_manager.clone() { let credentials = ApiCredentials { api_key: api_key.clone(), endpoint: normalized_endpoint.clone(), }; manager .store_credentials(OLLAMA_CLOUD_CREDENTIAL_ID, &credentials) .await .with_context(|| "Failed to store Ollama Cloud credentials securely")?; stored_securely = true; let mut config = self.controller.config_mut(); if let Some(entry) = config.providers.get_mut(&options.provider) { entry.api_key = None; } } else { self.push_toast( ToastLevel::Warning, "Secure credential vault unavailable; storing API key in configuration.", ); let mut config = self.controller.config_mut(); if let Some(entry) = config.providers.get_mut(&options.provider) { entry.api_key = Some(api_key.clone()); } } } else { let mut config = self.controller.config_mut(); if let Some(entry) = config.providers.get_mut(&options.provider) { entry.api_key = Some(api_key.clone()); } } if let Err(err) = config::save_config(&self.controller.config()) { return Err(anyhow!("Failed to save configuration: {}", err)); } if let Err(err) = self.refresh_models().await { self.push_toast( ToastLevel::Warning, format!("Cloud setup saved, but refreshing models failed: {err}"), ); } let mut status_parts = Vec::new(); status_parts.push(format!( "Configured {} for Ollama Cloud ({})", options.provider, normalized_endpoint )); if stored_securely { status_parts.push("API key stored securely".to_string()); } else { status_parts.push("API key stored in configuration".to_string()); } if !base_overridden && !options.force_cloud_base_url { status_parts.push("Local base URL preserved".to_string()); } self.status = status_parts.join(" · "); self.error = None; Ok(()) } async fn show_model_picker(&mut self, filter: Option) -> Result<()> { self.refresh_models().await?; if self.models.is_empty() { return Ok(()); } // Respect caller-specified filter or fall back to the last-used mode. if let Some(mode) = filter { self.model_filter_memory = mode; self.update_model_filter_mode(mode); } else { let remembered = self.model_filter_memory; self.update_model_filter_mode(remembered); } // Reset transient search state when opening the picker. self.reset_model_picker_state(); self.rebuild_model_selector_items(); self.update_model_search_status(); if self.available_providers.len() <= 1 { self.set_input_mode(InputMode::ModelSelection); self.ensure_valid_model_selection(); } else { self.set_input_mode(InputMode::ProviderSelection); } self.status = "Select a model to use".to_string(); Ok(()) } fn best_model_match_index(&self, query: &str) -> Option { let query = query.trim(); if query.is_empty() { return None; } let mut best: Option<(usize, usize, usize)> = None; for (idx, model) in self.models.iter().enumerate() { let mut candidates = Vec::new(); candidates.push(commands::match_score(model.id.as_str(), query)); if !model.name.is_empty() { candidates.push(commands::match_score(model.name.as_str(), query)); } candidates.push(commands::match_score( format!("{} {}", model.provider, model.id).as_str(), query, )); if !model.name.is_empty() { candidates.push(commands::match_score( format!("{} {}", model.provider, model.name).as_str(), query, )); } candidates.push(commands::match_score( format!("{}::{}", model.provider, model.id).as_str(), query, )); if let Some(score) = candidates.into_iter().flatten().min() { let entry = (score.0, score.1, idx); let replace = match best.as_ref() { Some(current) => entry < *current, None => true, }; if replace { best = Some(entry); } } } best.map(|(_, _, idx)| idx) } fn best_provider_match(&self, query: &str) -> Option { let query = query.trim(); if query.is_empty() { return None; } let mut best: Option<(usize, usize, &String)> = None; for provider in &self.available_providers { if let Some(score) = commands::match_score(provider.as_str(), query) { let entry = (score.0, score.1, provider); let replace = match best.as_ref() { Some(current) => entry < *current, None => true, }; if replace { best = Some(entry); } } } best.map(|(_, _, provider)| provider.clone()) } async fn select_model_with_filter(&mut self, filter: &str) -> Result<()> { let query = filter.trim(); if query.is_empty() { return Err(anyhow!( "Provide a model filter (e.g. :model llama3) or omit arguments to open the picker." )); } self.refresh_models().await?; if self.models.is_empty() { return Err(anyhow!( "No models available. Use :model to refresh once a provider is reachable." )); } if let Some(idx) = self.best_model_match_index(query) { if let Some(model) = self.models.get(idx).cloned() { self.apply_model_selection(model).await?; return Ok(()); } } Err(anyhow!(format!( "No model matching '{}'. Use :model to browse available models.", filter ))) } 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(); let mut references = Self::extract_resource_references(&message); references.sort(); references.dedup(); self.pending_resource_refs = references; 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 fn has_active_generation(&self) -> bool { self.pending_llm_request || !self.streaming.is_empty() } pub fn cancel_active_generation(&mut self) -> Result { let mut cancelled = false; if self.pending_llm_request { self.pending_llm_request = false; cancelled = true; } let mut cancel_error: Option = None; if !self.streaming.is_empty() { let active_ids: Vec = self.streaming.iter().copied().collect(); for message_id in active_ids { if let Some(handle) = self.stream_tasks.remove(&message_id) { handle.abort(); } if let Err(err) = self .controller .cancel_stream(message_id, "Generation cancelled by user.") { cancel_error = Some(err.to_string()); } self.streaming.remove(&message_id); self.invalidate_message_cache(&message_id); cancelled = true; } } if cancelled { if let Some(err) = cancel_error { self.error = Some(format!("Failed to finalize cancelled stream: {}", err)); } else { self.error = None; } self.stop_loading_animation(); self.pending_tool_execution = None; self.pending_consent = None; self.queued_consents.clear(); self.current_thinking = None; self.agent_actions = None; self.status = "Generation cancelled".to_string(); self.set_system_status("Generation cancelled".to_string()); self.update_thinking_from_last_message(); } Ok(cancelled) } fn reset_after_new_conversation(&mut self) -> Result<()> { let _ = self.cancel_active_generation()?; self.close_code_view(); self.set_system_status(String::new()); self.pending_llm_request = false; self.pending_tool_execution = None; self.pending_consent = None; self.queued_consents.clear(); self.pending_key = None; self.visual_start = None; self.visual_end = None; self.clipboard.clear(); { let buffer = self.controller.input_buffer_mut(); buffer.clear(); buffer.clear_history(); } self.textarea = TextArea::default(); configure_textarea_defaults(&mut self.textarea); self.auto_scroll = AutoScroll::default(); self.thinking_scroll = AutoScroll::default(); self.chat_cursor = (0, 0); self.thinking_cursor = (0, 0); self.current_thinking = None; self.agent_actions = None; self.agent_mode = false; self.agent_running = false; self.is_loading = false; self.message_line_cache.clear(); // Ensure no orphaned stream tasks remain for (_, handle) in self.stream_tasks.drain() { handle.abort(); } self.streaming.clear(); self.focused_panel = FocusedPanel::Input; self.ensure_focus_valid(); Ok(()) } pub async fn process_pending_llm_request(&mut self) -> Result<()> { if !self.pending_llm_request { return Ok(()); } self.pending_llm_request = false; self.resolve_pending_resource_references().await?; // 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))) => { if let Some(usage) = response.usage.as_ref() { self.update_context_usage(usage); } self.stop_loading_animation(); self.status = "Ready".to_string(); self.error = None; self.refresh_usage_summary().await?; 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)) => { self.stop_loading_animation(); if self.handle_provider_error(&err).await? { return Ok(()); } 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.set_input_mode(InputMode::ProviderSelection); } else { self.error = Some(message); self.status = "Request failed".to_string(); } 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 handle_provider_error(&mut self, err: &CoreError) -> Result { let current_provider = Self::canonical_provider_id(&self.current_provider); if current_provider != "ollama_cloud" { return Ok(false); } match err { CoreError::Auth(message) => { self.push_toast( ToastLevel::Error, "Cloud key invalid; using local provider.", ); let switch_result = self.switch_to_provider("ollama_local").await; if let Err(switch_err) = switch_result { let detail = format!( "Cloud key invalid and local fallback failed: {}", switch_err ); self.error = Some(detail.clone()); self.status = "Cloud authentication failed".to_string(); self.push_toast(ToastLevel::Error, detail); } else { self.selected_provider = "ollama_local".to_string(); self.expanded_provider = Some("ollama_local".to_string()); self.update_selected_provider_index(); { let mut cfg = self.controller.config_mut(); cfg.general.default_provider = "ollama_local".to_string(); } let save_result = { let cfg = self.controller.config(); config::save_config(&cfg) }; if let Err(save_err) = save_result { self.push_toast( ToastLevel::Warning, format!( "Fell back to local provider, but failed to save config: {}", save_err ), ); } if let Err(refresh_err) = self.refresh_models().await { self.push_toast( ToastLevel::Warning, format!("Failed to refresh local models: {}", refresh_err), ); } self.status = "Cloud authentication failed; using local provider instead.".to_string(); self.error = Some(format!( "Cloud key invalid: {}. Update your credentials and reselect the cloud provider.", message )); self.push_toast(ToastLevel::Info, "Switched back to local provider."); } Ok(true) } CoreError::Network(message) => { let lower = message.to_ascii_lowercase(); if message.contains("429") || lower.contains("too many requests") || lower.contains("rate limit") { self.error = Some("Cloud rate limit hit; retry later.".to_string()); self.status = "Cloud rate limit hit".to_string(); self.push_toast(ToastLevel::Warning, "Cloud rate limit hit; retry later."); return Ok(true); } Ok(false) } _ => Ok(false), } } 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<()> { let Some((message_id, tool_calls)) = self.pending_tool_execution.take() else { return Ok(()); }; // If a consent dialog is active, keep the execution queued until it resolves if self.pending_consent.is_some() { self.pending_tool_execution = Some((message_id, tool_calls)); return Ok(()); } // 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() { // Re-queue the execution and ensure a controller event is emitted self.pending_tool_execution = Some((message_id, tool_calls)); self.controller.check_streaming_tool_calls(message_id); 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 => { let conversation = self.conversation(); let mut formatter = self.formatter().clone(); let body_width = self.content_width.max(1); let mut card_width = body_width.saturating_add(4); let mut compact_cards = false; if card_width < MIN_MESSAGE_CARD_WIDTH { card_width = body_width.saturating_add(2).max(1); compact_cards = true; } let inner_width = if compact_cards { card_width.saturating_sub(2).max(1) } else { card_width.saturating_sub(4).max(1) }; formatter.set_wrap_width(body_width); let role_label_mode = formatter.role_label_mode(); let mut lines = Vec::new(); for message in conversation.messages.iter() { let role = &message.role; let content_to_display = if matches!(role, Role::Assistant) { let (content_without_think, _) = formatter.extract_thinking(&message.content); content_without_think } else if matches!(role, Role::Tool) { format_tool_output(&message.content) } else { message.content.clone() }; let is_streaming = message .metadata .get("streaming") .and_then(|value| value.as_bool()) .unwrap_or(false); let normalized_content = content_to_display.replace("\r\n", "\n"); let trimmed = normalized_content.trim(); let segments = parse_message_segments(trimmed, self.render_markdown); let mut body_lines: Vec = Vec::new(); let mut indicator_target: Option = None; let mut append_segments_plain = |segments: &[MessageSegment], indent: &str, available_width: usize, indicator_target: &mut Option, code_width: usize| { if segments.is_empty() { let line_text = if indent.is_empty() { String::new() } else { indent.to_string() }; body_lines.push(line_text); *indicator_target = Some(body_lines.len() - 1); return; } for segment in segments { match segment { MessageSegment::Text { lines: seg_lines } => { for line_text in seg_lines { let mut chunks = wrap_unicode(line_text.as_str(), available_width); if chunks.is_empty() { chunks.push(String::new()); } for chunk in chunks { let text = if indent.is_empty() { chunk.clone() } else { format!("{indent}{chunk}") }; body_lines.push(text); *indicator_target = Some(body_lines.len() - 1); } } } MessageSegment::CodeBlock { language, lines: code_lines, } => { append_code_block_lines_plain( &mut body_lines, indent, code_width, language.as_deref(), code_lines, indicator_target, ); } } } }; match role_label_mode { RoleLabelDisplay::Above => { let indent = " "; let indent_width = UnicodeWidthStr::width(indent); let available_width = body_width.saturating_sub(indent_width).max(1); append_segments_plain( &segments, indent, available_width, &mut indicator_target, body_width.saturating_sub(indent_width), ); } RoleLabelDisplay::Inline | RoleLabelDisplay::None => { let indent = ""; let available_width = body_width.max(1); append_segments_plain( &segments, indent, available_width, &mut indicator_target, body_width, ); } } let loading_indicator = self.get_loading_indicator(); if is_streaming && !loading_indicator.is_empty() { let spinner_symbol = streaming_indicator_symbol(loading_indicator); if let Some(idx) = indicator_target { if let Some(line) = body_lines.get_mut(idx) { if !line.is_empty() { line.push(' '); } line.push_str(spinner_symbol); } } else if let Some(line) = body_lines.last_mut() { if !line.is_empty() { line.push(' '); } line.push_str(spinner_symbol); } else { body_lines.push(spinner_symbol.to_string()); } } let formatted_timestamp = if self.show_message_timestamps { Some(Self::format_message_timestamp(message.timestamp)) } else { None }; let markers = Self::message_tool_markers( role, message.tool_calls.as_ref(), message .metadata .get("tool_call_id") .and_then(|value| value.as_str()), ); if compact_cards { let (emoji, title) = role_label_parts(role); let mut header = format!("{emoji} {title}"); if let Some(ts) = formatted_timestamp.as_deref() { header.push_str(" · "); header.push_str(ts); } for marker in &markers { header.push(' '); header.push_str(marker); } lines.push(header); if body_lines.is_empty() { lines.push(String::new()); } else { lines.extend(body_lines); } lines.push(String::new()); } else { lines.push(Self::build_card_header_plain( role, formatted_timestamp.as_deref(), &markers, card_width, )); if body_lines.is_empty() { lines.push(Self::wrap_card_body_line_plain("", inner_width)); } else { for body_line in body_lines { lines .push(Self::wrap_card_body_line_plain(&body_line, inner_width)); } } lines.push(Self::build_card_footer_plain(card_width)); } } let last_message_is_user = conversation .messages .last() .map(|msg| matches!(msg.role, Role::User)) .unwrap_or(true); if !self.get_loading_indicator().is_empty() && last_message_is_user { lines.push(format!("🤖 Assistant: {}", self.get_loading_indicator())); } if self.chat_line_offset > 0 { let skip = self.chat_line_offset.min(lines.len()); lines = lines.into_iter().skip(skip).collect(); } if lines.is_empty() { lines.push("No messages yet. Press 'i' to start typing.".to_string()); } lines } FocusedPanel::Thinking => { if let Some(thinking) = &self.current_thinking { thinking.lines().map(|s| s.to_string()).collect() } else { Vec::new() } } FocusedPanel::Files => Vec::new(), FocusedPanel::Code => { if self.has_loaded_code_view() { self.code_view_lines() .iter() .enumerate() .map(|(idx, line)| format!("{:>4} {}", idx + 1, line)) .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::ChatStream) { let sender = self.session_tx.clone(); self.streaming.insert(message_id); let handle = 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_id: Some(message_id), message: e.to_string(), }); break; } } } }); self.stream_tasks.insert(message_id, handle); } } fn capitalize_first(input: &str) -> String { let mut chars = input.chars(); if let Some(first) = chars.next() { let mut result = first.to_uppercase().collect::(); result.push_str(chars.as_str()); result } else { String::new() } } pub(crate) fn role_label_parts(role: &Role) -> (&'static str, &'static str) { match role { Role::User => ("👤", "You"), Role::Assistant => ("🤖", "Assistant"), Role::System => ("⚙️", "System"), Role::Tool => ("🔧", "Tool"), } } pub(crate) fn max_inline_label_width() -> usize { [ ("👤", "You"), ("🤖", "Assistant"), ("⚙️", "System"), ("🔧", "Tool"), ] .iter() .map(|(emoji, title)| { let measure = format!("{emoji} {title}:"); UnicodeWidthStr::width(measure.as_str()) }) .max() .unwrap_or(0) } pub(crate) fn streaming_indicator_symbol(indicator: &str) -> &str { if indicator.is_empty() { "▌" } else { indicator } } fn parse_message_segments(content: &str, markdown_enabled: bool) -> Vec { if !markdown_enabled { let mut lines: Vec = content.lines().map(|line| line.to_string()).collect(); if lines.is_empty() { lines.push(String::new()); } return vec![MessageSegment::Text { lines }]; } let mut segments = Vec::new(); let mut text_lines: Vec = Vec::new(); let mut lines = content.lines(); while let Some(line) = lines.next() { let trimmed = line.trim_start(); if trimmed.starts_with("```") { if !text_lines.is_empty() { segments.push(MessageSegment::Text { lines: std::mem::take(&mut text_lines), }); } let language = trimmed .trim_start_matches("```") .split_whitespace() .next() .unwrap_or("") .to_string(); let mut code_lines = Vec::new(); for code_line in lines.by_ref() { if code_line.trim_start().starts_with("```") { break; } code_lines.push(code_line.to_string()); } segments.push(MessageSegment::CodeBlock { language: if language.is_empty() { None } else { Some(language) }, lines: code_lines, }); } else { text_lines.push(line.to_string()); } } if !text_lines.is_empty() { segments.push(MessageSegment::Text { lines: text_lines }); } else if segments.is_empty() { segments.push(MessageSegment::Text { lines: vec![String::new()], }); } segments } fn wrap_code(text: &str, width: usize) -> Vec { if width == 0 { return vec![String::new()]; } let options = Options::new(width) .word_separator(WordSeparator::UnicodeBreakProperties) .break_words(true); let mut wrapped: Vec = wrap(text, options) .into_iter() .map(|segment| segment.into_owned()) .collect(); if wrapped.is_empty() { wrapped.push(String::new()); } wrapped } fn render_markdown_lines( markdown: &str, indent: &str, available_width: usize, base_style: Style, ) -> Vec> { let width = available_width.max(1); let lines: Vec<&str> = if markdown.is_empty() { Vec::new() } else { markdown.lines().collect() }; if lines.is_empty() { return render_markdown_text_block(markdown, indent, width, base_style); } let mut output: Vec> = Vec::new(); let mut buffer: Vec<&str> = Vec::new(); let mut index = 0usize; while index < lines.len() { if let Some((table, next_index)) = parse_markdown_table(&lines, index) { if !buffer.is_empty() { let block = buffer.join("\n"); output.extend(render_markdown_text_block( &block, indent, width, base_style, )); buffer.clear(); } output.extend(render_markdown_table(&table, indent, width, base_style)); index = next_index; } else { buffer.push(lines[index]); index += 1; } } if !buffer.is_empty() { let block = buffer.join("\n"); output.extend(render_markdown_text_block( &block, indent, width, base_style, )); } if output.is_empty() { output.extend(render_markdown_text_block("", indent, width, base_style)); } output } fn render_markdown_text_block( markdown: &str, indent: &str, width: usize, base_style: Style, ) -> Vec> { let mut text = from_str(markdown); let mut output: Vec> = Vec::new(); if text.lines.is_empty() { let wrapped = wrap_markdown_spans(Vec::new(), indent, width, base_style); output.extend(wrapped); } else { for line in text.lines.drain(..) { let spans_owned = line .spans .into_iter() .map(|span| { let owned = span.content.into_owned(); Span::styled(owned, span.style) }) .collect::>(); let wrapped = wrap_markdown_spans(spans_owned, indent, width, base_style); output.extend(wrapped); } } if output.is_empty() { output.push(blank_line(indent, base_style)); } output } #[derive(Debug)] struct ParsedTable { headers: Vec, rows: Vec>, alignments: Vec, } #[derive(Clone, Copy, Debug)] enum TableAlignment { Left, Center, Right, } fn parse_markdown_table(lines: &[&str], start: usize) -> Option<(ParsedTable, usize)> { if start + 1 >= lines.len() { return None; } let header_line = lines[start].trim(); let alignment_line = lines[start + 1].trim(); if header_line.is_empty() || !header_line.contains('|') { return None; } let headers = split_table_row(header_line); if headers.is_empty() { return None; } let alignments = parse_alignment_row(alignment_line, headers.len())?; let mut rows = Vec::new(); let mut index = start + 2; while index < lines.len() { let raw = lines[index]; let trimmed = raw.trim(); if trimmed.is_empty() || !trimmed.contains('|') { break; } if trimmed .chars() .all(|ch| matches!(ch, '-' | ':' | '|' | ' ')) { break; } let mut cells = split_table_row(trimmed); if cells.iter().all(|cell| cell.is_empty()) { break; } if cells.len() < headers.len() { cells.resize(headers.len(), String::new()); } else if cells.len() > headers.len() { cells.truncate(headers.len()); } rows.push(cells); index += 1; } Some(( ParsedTable { headers, rows, alignments, }, index, )) } fn split_table_row(line: &str) -> Vec { let trimmed = line.trim(); if trimmed.is_empty() { return vec![String::new()]; } let mut chars = trimmed.chars().peekable(); // Discard a single leading pipe if present. if matches!(chars.peek(), Some('|')) { chars.next(); } let mut cells = Vec::new(); let mut current = String::new(); let mut escape = false; for ch in chars { if escape { current.push(ch); escape = false; continue; } match ch { '\\' => { escape = true; } '|' => { cells.push(current.trim().to_string()); current.clear(); } _ => current.push(ch), } } if escape { current.push('\\'); } if !trimmed.ends_with('|') || !current.trim().is_empty() { cells.push(current.trim().to_string()); } if cells.is_empty() { cells.push(String::new()); } cells } fn parse_alignment_row(line: &str, expected_columns: usize) -> Option> { let trimmed = line.trim(); if trimmed.is_empty() || !trimmed.contains('-') { return None; } let raw_cells = split_table_row(trimmed); if raw_cells.len() != expected_columns { return None; } let mut alignments = Vec::with_capacity(expected_columns); for cell in raw_cells { let cell_trimmed = cell.trim(); if cell_trimmed.is_empty() { alignments.push(TableAlignment::Left); continue; } if !cell_trimmed.chars().all(|ch| matches!(ch, '-' | ':' | ' ')) { return None; } if !cell_trimmed.contains('-') { return None; } let left = cell_trimmed.starts_with(':'); let right = cell_trimmed.ends_with(':'); let alignment = match (left, right) { (true, true) => TableAlignment::Center, (false, true) => TableAlignment::Right, _ => TableAlignment::Left, }; alignments.push(alignment); } Some(alignments) } fn render_markdown_table( table: &ParsedTable, indent: &str, available_width: usize, base_style: Style, ) -> Vec> { const MIN_CELL_WIDTH: usize = 3; if table.headers.is_empty() { return render_markdown_text_block("", indent, available_width.max(1), base_style); } let indent_width = UnicodeWidthStr::width(indent); let padding_cost = 1 + table.headers.len() * 3; let available_content = available_width.saturating_sub(indent_width + padding_cost); if available_content < table.headers.len().saturating_mul(MIN_CELL_WIDTH) { return render_markdown_table_stacked(table, indent, available_width, base_style); } let mut desired_widths = vec![MIN_CELL_WIDTH; table.headers.len()]; for (index, header) in table.headers.iter().enumerate() { desired_widths[index] = desired_widths[index].max(cell_display_width(header)); } for row in &table.rows { for (index, cell) in row.iter().enumerate().take(desired_widths.len()) { desired_widths[index] = desired_widths[index].max(cell_display_width(cell)); } } let constrained = constrain_column_widths(&desired_widths, available_content, MIN_CELL_WIDTH) .unwrap_or_else(|| vec![MIN_CELL_WIDTH; table.headers.len()]); if constrained.iter().sum::() > available_content { return render_markdown_table_stacked(table, indent, available_width, base_style); } render_markdown_table_grid(table, indent, available_width, base_style, &constrained) } fn render_markdown_table_grid( table: &ParsedTable, indent: &str, available_width: usize, base_style: Style, column_widths: &[usize], ) -> Vec> { let mut output = Vec::new(); output.extend(render_table_summary_lines( table, indent, available_width, base_style, )); output.push(blank_line(indent, base_style)); output.push(build_table_border_line( '┌', '┬', '┐', column_widths, indent, base_style, )); let header_styles = vec![base_style.add_modifier(Modifier::BOLD); table.headers.len()]; output.extend(render_table_row_lines( &table.headers, column_widths, &table.alignments, indent, base_style, &header_styles, )); if table.rows.is_empty() { output.push(build_table_border_line( '└', '┴', '┘', column_widths, indent, base_style, )); return output; } output.push(build_table_border_line( '├', '┼', '┤', column_widths, indent, base_style, )); let body_styles = vec![base_style; table.headers.len()]; for (index, row) in table.rows.iter().enumerate() { output.extend(render_table_row_lines( row, column_widths, &table.alignments, indent, base_style, &body_styles, )); if index == table.rows.len() - 1 { output.push(build_table_border_line( '└', '┴', '┘', column_widths, indent, base_style, )); } else { output.push(build_table_border_line( '├', '┼', '┤', column_widths, indent, base_style, )); } } output } fn render_markdown_table_stacked( table: &ParsedTable, indent: &str, available_width: usize, base_style: Style, ) -> Vec> { let mut output = Vec::new(); output.extend(render_table_summary_lines( table, indent, available_width, base_style, )); if table.rows.is_empty() { output.push(blank_line(indent, base_style)); let mut spans = Vec::new(); if !indent.is_empty() { spans.push(Span::styled(indent.to_string(), base_style)); } spans.push(Span::styled( "(No rows)", base_style.add_modifier(Modifier::ITALIC), )); output.push(Line::from(spans)); return output; } output.push(blank_line(indent, base_style)); let indent_width = UnicodeWidthStr::width(indent); let available = available_width.saturating_sub(indent_width).max(1); let header_style = base_style.add_modifier(Modifier::BOLD); for (row_index, row) in table.rows.iter().enumerate() { if row_index > 0 { output.push(blank_line(indent, base_style)); } for (column_index, header) in table.headers.iter().enumerate() { let value = row.get(column_index).map(String::as_str).unwrap_or(""); let bullet_prefix = if column_index == 0 { format!("• {}: ", header) } else { format!(" {}: ", header) }; let prefix_width = UnicodeWidthStr::width(bullet_prefix.as_str()); let value_width = available.saturating_sub(prefix_width); if value_width == 0 { let mut spans = Vec::new(); if !indent.is_empty() { spans.push(Span::styled(indent.to_string(), base_style)); } spans.push(Span::styled(bullet_prefix.clone(), header_style)); output.push(Line::from(spans)); let wrapped_values = wrap_table_cell(value, available.max(1)); for wrapped in wrapped_values { let mut continuation = Vec::new(); if !indent.is_empty() { continuation.push(Span::styled(indent.to_string(), base_style)); } continuation.push(Span::styled(" ".repeat(prefix_width), header_style)); continuation.push(Span::styled(wrapped, base_style)); output.push(Line::from(continuation)); } continue; } let wrapped_values = wrap_table_cell(value, value_width.max(1)); for (line_index, wrapped) in wrapped_values.into_iter().enumerate() { let mut spans = Vec::new(); if !indent.is_empty() { spans.push(Span::styled(indent.to_string(), base_style)); } if line_index == 0 { spans.push(Span::styled(bullet_prefix.clone(), header_style)); } else { spans.push(Span::styled(" ".repeat(prefix_width), header_style)); } spans.push(Span::styled(wrapped, base_style)); output.push(Line::from(spans)); } } } output } fn render_table_summary_lines( table: &ParsedTable, indent: &str, available_width: usize, base_style: Style, ) -> Vec> { let mut output = Vec::new(); let indent_width = UnicodeWidthStr::width(indent); let summary_width = available_width.saturating_sub(indent_width).max(1); let column_list = if table.headers.is_empty() { String::from("No columns") } else { table.headers.join(", ") }; let row_count = table.rows.len(); let prefix = if row_count == 0 { "Table:".to_string() } else if row_count == 1 { "Table (1 row):".to_string() } else { format!("Table ({} rows):", row_count) }; let prefix_width = UnicodeWidthStr::width(prefix.as_str()); if summary_width <= prefix_width + 1 { let mut combined = prefix.clone(); if !column_list.is_empty() { combined.push(' '); combined.push_str(&column_list); } let wrapped = { let mut result = wrap_unicode(combined.as_str(), summary_width); if result.is_empty() { result.push(String::new()); } result }; for (index, text) in wrapped.into_iter().enumerate() { let mut spans = Vec::new(); if !indent.is_empty() { spans.push(Span::styled(indent.to_string(), base_style)); } let style = if index == 0 { base_style.add_modifier(Modifier::BOLD) } else { base_style }; spans.push(Span::styled(text, style)); output.push(Line::from(spans)); } return output; } let rest_width = summary_width.saturating_sub(prefix_width + 1).max(1); let mut rest_lines = if column_list.is_empty() { vec![String::new()] } else { let mut wrapped = wrap_unicode(column_list.as_str(), rest_width); if wrapped.is_empty() { wrapped.push(String::new()); } wrapped }; for (index, text) in rest_lines.drain(..).enumerate() { let mut spans = Vec::new(); if !indent.is_empty() { spans.push(Span::styled(indent.to_string(), base_style)); } if index == 0 { spans.push(Span::styled( format!("{prefix} "), base_style.add_modifier(Modifier::BOLD), )); spans.push(Span::styled(text, base_style)); } else { spans.push(Span::styled(" ".repeat(prefix_width + 1), base_style)); spans.push(Span::styled(text, base_style)); } output.push(Line::from(spans)); } output } fn build_table_border_line( left: char, mid: char, right: char, column_widths: &[usize], indent: &str, base_style: Style, ) -> Line<'static> { let mut line = String::new(); line.push(left); for (index, width) in column_widths.iter().enumerate() { line.push_str(&"─".repeat(width + 2)); if index == column_widths.len() - 1 { line.push(right); } else { line.push(mid); } } let mut spans = Vec::new(); if !indent.is_empty() { spans.push(Span::styled(indent.to_string(), base_style)); } spans.push(Span::styled(line, base_style)); Line::from(spans) } fn render_table_row_lines( cells: &[String], column_widths: &[usize], alignments: &[TableAlignment], indent: &str, border_style: Style, cell_styles: &[Style], ) -> Vec> { let column_count = column_widths.len(); let mut column_lines: Vec> = Vec::with_capacity(column_count); for (index, width) in column_widths.iter().enumerate() { let cell = cells.get(index).map(String::as_str).unwrap_or(""); column_lines.push(wrap_table_cell(cell, (*width).max(1))); } let max_height = column_lines .iter() .map(|lines| lines.len()) .max() .unwrap_or(1); let mut output = Vec::with_capacity(max_height); for line_index in 0..max_height { let mut spans = Vec::new(); if !indent.is_empty() { spans.push(Span::styled(indent.to_string(), border_style)); } spans.push(Span::styled("│".to_string(), border_style)); for (column_index, width) in column_widths.iter().enumerate() { let content = column_lines .get(column_index) .and_then(|lines| lines.get(line_index)) .map(|s| s.as_str()) .unwrap_or(""); let alignment = alignments .get(column_index) .copied() .unwrap_or(TableAlignment::Left); let aligned = align_cell_line(alignment, content, *width); let cell_style = cell_styles .get(column_index) .copied() .unwrap_or(border_style); spans.push(Span::styled(" ".to_string(), border_style)); spans.push(Span::styled(aligned, cell_style)); spans.push(Span::styled(" ".to_string(), border_style)); spans.push(Span::styled("│".to_string(), border_style)); } output.push(Line::from(spans)); } output } fn align_cell_line(alignment: TableAlignment, content: &str, width: usize) -> String { let display_width = UnicodeWidthStr::width(content); if display_width >= width { return content.to_string(); } let padding = width - display_width; match alignment { TableAlignment::Left => format!("{content}{}", " ".repeat(padding)), TableAlignment::Right => format!("{}{}", " ".repeat(padding), content), TableAlignment::Center => { let left = padding / 2; let right = padding - left; format!("{}{}{}", " ".repeat(left), content, " ".repeat(right)) } } } fn wrap_table_cell(content: &str, width: usize) -> Vec { if width == 0 { return vec![String::new()]; } let mut lines = Vec::new(); for segment in content.split('\n') { let trimmed = segment.trim_end(); if trimmed.is_empty() { lines.push(String::new()); continue; } let options = Options::new(width) .word_separator(WordSeparator::UnicodeBreakProperties) .break_words(true); let wrapped = wrap(trimmed, options); if wrapped.is_empty() { lines.push(String::new()); } else { lines.extend(wrapped.into_iter().map(|line| line.into_owned())); } } if lines.is_empty() { lines.push(String::new()); } lines } fn constrain_column_widths( desired: &[usize], available: usize, min_width: usize, ) -> Option> { if desired.is_empty() { return Some(Vec::new()); } if available < min_width.saturating_mul(desired.len()) { return None; } let mut widths: Vec = desired .iter() .map(|value| (*value).max(min_width)) .collect(); let mut total: usize = widths.iter().sum(); if total <= available { return Some(widths); } while total > available { let mut changed = false; for value in &mut widths { if total <= available { break; } if *value > min_width { *value -= 1; total -= 1; changed = true; } } if !changed { break; } } if total > available { None } else { Some(widths) } } fn cell_display_width(value: &str) -> usize { value .split('\n') .map(|segment| UnicodeWidthStr::width(segment.trim_end())) .max() .unwrap_or(0) .max(1) } fn blank_line(indent: &str, base_style: Style) -> Line<'static> { let mut spans = Vec::new(); if !indent.is_empty() { spans.push(Span::styled(indent.to_string(), base_style)); } spans.push(Span::styled(String::new(), base_style)); Line::from(spans) } fn wrap_markdown_spans( spans: Vec>, indent: &str, available_width: usize, base_style: Style, ) -> Vec> { let width = available_width.max(1); if spans.is_empty() { let mut line_spans = Vec::new(); if !indent.is_empty() { line_spans.push(Span::styled(indent.to_string(), base_style)); } line_spans.push(Span::styled(String::new(), base_style)); return vec![Line::from(line_spans)]; } let mut result: Vec> = Vec::new(); let mut current: Vec> = Vec::new(); let mut remaining = width; if !indent.is_empty() { current.push(Span::styled(indent.to_string(), base_style)); remaining = remaining.saturating_sub(UnicodeWidthStr::width(indent)); } for span in spans { let mut content = span.content.into_owned(); let style = span.style; if content.is_empty() { continue; } while !content.is_empty() { if remaining == 0 { result.push(Line::from(std::mem::take(&mut current))); if !indent.is_empty() { current.push(Span::styled(indent.to_string(), base_style)); } remaining = width; if !indent.is_empty() { remaining = remaining.saturating_sub(UnicodeWidthStr::width(indent)); } } let available = remaining; let mut take_bytes = 0; let mut take_width = 0; for grapheme in content.graphemes(true) { let grapheme_width = UnicodeWidthStr::width(grapheme); if take_width + grapheme_width > available { break; } take_bytes += grapheme.len(); take_width += grapheme_width; if take_width == available { break; } } if take_bytes == 0 { result.push(Line::from(std::mem::take(&mut current))); if !indent.is_empty() { current.push(Span::styled(indent.to_string(), base_style)); } remaining = width; if !indent.is_empty() { remaining = remaining.saturating_sub(UnicodeWidthStr::width(indent)); } continue; } let chunk = content[..take_bytes].to_string(); content = content[take_bytes..].to_string(); current.push(Span::styled(chunk, style)); remaining = remaining.saturating_sub(take_width); } } if current.is_empty() { if !indent.is_empty() { current.push(Span::styled(indent.to_string(), base_style)); } current.push(Span::styled(String::new(), base_style)); } result.push(Line::from(current)); result } fn wrap_highlight_segments( segments: Vec<(Style, String)>, code_width: usize, theme: &Theme, ) -> Vec> { let mut rows: Vec> = Vec::new(); let mut current: Vec<(Style, String)> = Vec::new(); let mut current_width: usize = 0; let push_row = |rows: &mut Vec>, current: &mut Vec<(Style, String)>, current_width: &mut usize| { rows.push(std::mem::take(current)); *current_width = 0; }; for (style_raw, text) in segments { let mut remaining = text.as_str(); if remaining.is_empty() { continue; } while !remaining.is_empty() { if current_width >= code_width { push_row(&mut rows, &mut current, &mut current_width); } let available = code_width.saturating_sub(current_width); if available == 0 { push_row(&mut rows, &mut current, &mut current_width); continue; } let mut take_bytes = 0; let mut take_width = 0; for grapheme in remaining.graphemes(true) { let grapheme_width = UnicodeWidthStr::width(grapheme); if take_width + grapheme_width > available { break; } take_bytes += grapheme.len(); take_width += grapheme_width; if take_width == available { break; } } if take_bytes == 0 { push_row(&mut rows, &mut current, &mut current_width); continue; } let chunk = &remaining[..take_bytes]; remaining = &remaining[take_bytes..]; let mut style = style_raw; if style.fg.is_none() { style = style.fg(theme.code_block_text); } style = style.bg(theme.code_block_background); current.push((style, chunk.to_string())); current_width += take_width; } } if !current.is_empty() { rows.push(current); } else if rows.is_empty() { rows.push(Vec::new()); } rows } fn inline_code_spans_from_text(text: &str, theme: &Theme, base_style: Style) -> Vec> { let tick_count = text.matches('`').count(); if tick_count < 2 || (tick_count & 1) != 0 { return vec![Span::styled(text.to_string(), base_style)]; } let code_style = Style::default() .fg(theme.code_block_text) .bg(theme.code_block_background) .add_modifier(Modifier::BOLD); let mut spans = Vec::new(); let mut buffer = String::new(); let mut in_code = false; for ch in text.chars() { if ch == '`' { if in_code { if !buffer.is_empty() { spans.push(Span::styled(buffer.clone(), code_style)); buffer.clear(); } } else if !buffer.is_empty() { spans.push(Span::styled(buffer.clone(), base_style)); buffer.clear(); } in_code = !in_code; } else { buffer.push(ch); } } if in_code { return vec![Span::styled(text.to_string(), base_style)]; } if !buffer.is_empty() { spans.push(Span::styled(buffer, base_style)); } if spans.is_empty() { spans.push(Span::styled(String::new(), base_style)); } spans } #[allow(clippy::too_many_arguments)] fn append_code_block_lines( rendered: &mut Vec>, indent: &str, body_width: usize, language: Option<&str>, code_lines: &[String], theme: &Theme, syntax_highlighting: bool, indicator_target: &mut Option, ) { let body_width = body_width.max(4); let inner_width = body_width.saturating_sub(2); let code_width = inner_width.max(1); let border_style = Style::default() .fg(theme.code_block_border) .bg(theme.code_block_background); let label_style = Style::default() .fg(theme.code_block_text) .bg(theme.code_block_background) .add_modifier(Modifier::BOLD); let text_style = Style::default() .fg(theme.code_block_text) .bg(theme.code_block_background); let mut top_spans = Vec::new(); top_spans.push(Span::styled(indent.to_string(), border_style)); top_spans.push(Span::styled("╭", border_style)); let language_label = language .and_then(|lang| { let trimmed = lang.trim(); if trimmed.is_empty() { None } else { Some(trimmed) } }) .map(|label| format!(" {} ", label)); if inner_width > 0 { if let Some(label) = language_label { let label_width = UnicodeWidthStr::width(label.as_str()); if label_width < inner_width { let left = (inner_width - label_width) / 2; let right = inner_width - label_width - left; if left > 0 { top_spans.push(Span::styled("─".repeat(left), border_style)); } top_spans.push(Span::styled(label, label_style)); if right > 0 { top_spans.push(Span::styled("─".repeat(right), border_style)); } } else { top_spans.push(Span::styled("─".repeat(inner_width), border_style)); } } else { top_spans.push(Span::styled("─".repeat(inner_width), border_style)); } } top_spans.push(Span::styled("╮", border_style)); rendered.push(Line::from(top_spans)); let mut highlighter = if syntax_highlighting { Some(highlight::build_highlighter_for_language(language)) } else { None }; let mut process_line = |line: &str| { let segments = if let Some(highlighter) = highlighter.as_mut() { let mut segments = highlight::highlight_line(highlighter, line); if segments.is_empty() { segments.push((Style::default(), String::new())); } segments } else { vec![(Style::default(), line.to_string())] }; let has_content = segments.iter().any(|(_, text)| !text.is_empty()); let rows = if has_content { wrap_highlight_segments(segments, code_width, theme) } else { vec![Vec::new()] }; for row in rows { let mut spans = Vec::new(); spans.push(Span::styled(indent.to_string(), border_style)); spans.push(Span::styled("│", border_style)); let mut row_width = 0; if row.is_empty() { spans.push(Span::styled(" ".repeat(code_width), text_style)); } else { for (style, piece) in row { let width = UnicodeWidthStr::width(piece.as_str()); row_width += width; spans.push(Span::styled(piece, style)); } if row_width < code_width { spans.push(Span::styled(" ".repeat(code_width - row_width), text_style)); } } spans.push(Span::styled("│", border_style)); rendered.push(Line::from(spans)); *indicator_target = Some(rendered.len() - 1); } }; if code_lines.is_empty() { process_line(""); } else { for line in code_lines { process_line(line); } } let mut bottom_spans = Vec::new(); bottom_spans.push(Span::styled(indent.to_string(), border_style)); bottom_spans.push(Span::styled("╰", border_style)); if inner_width > 0 { bottom_spans.push(Span::styled("─".repeat(inner_width), border_style)); } bottom_spans.push(Span::styled("╯", border_style)); rendered.push(Line::from(bottom_spans)); } fn append_code_block_lines_plain( output: &mut Vec, indent: &str, body_width: usize, language: Option<&str>, code_lines: &[String], indicator_target: &mut Option, ) { let body_width = body_width.max(4); let inner_width = body_width.saturating_sub(2); let code_width = inner_width.max(1); let mut top_line = String::new(); top_line.push_str(indent); top_line.push('╭'); if inner_width > 0 { if let Some(label) = language.and_then(|lang| { let trimmed = lang.trim(); if trimmed.is_empty() { None } else { Some(trimmed) } }) { let label_text = format!(" {} ", label); let label_width = UnicodeWidthStr::width(label_text.as_str()); if label_width < inner_width { let left = (inner_width - label_width) / 2; let right = inner_width - label_width - left; top_line.push_str(&"─".repeat(left)); top_line.push_str(&label_text); top_line.push_str(&"─".repeat(right)); } else { top_line.push_str(&"─".repeat(inner_width)); } } else { top_line.push_str(&"─".repeat(inner_width)); } } top_line.push('╮'); output.push(top_line); if code_lines.is_empty() { let chunks = wrap_code("", code_width); for chunk in chunks { let mut line = String::new(); line.push_str(indent); line.push('│'); line.push_str(&chunk); let display_width = UnicodeWidthStr::width(chunk.as_str()); if display_width < code_width { line.push_str(&" ".repeat(code_width - display_width)); } line.push('│'); output.push(line); *indicator_target = Some(output.len() - 1); } } else { for line_text in code_lines { let chunks = wrap_code(line_text.as_str(), code_width); for chunk in chunks { let mut line = String::new(); line.push_str(indent); line.push('│'); line.push_str(&chunk); let display_width = UnicodeWidthStr::width(chunk.as_str()); if display_width < code_width { line.push_str(&" ".repeat(code_width - display_width)); } line.push('│'); output.push(line); *indicator_target = Some(output.len() - 1); } } } let mut bottom_line = String::new(); bottom_line.push_str(indent); bottom_line.push('╰'); if inner_width > 0 { bottom_line.push_str(&"─".repeat(inner_width)); } bottom_line.push('╯'); output.push(bottom_line); } pub(crate) fn wrap_unicode(text: &str, width: usize) -> Vec { if width == 0 { return Vec::new(); } let options = Options::new(width) .word_separator(WordSeparator::UnicodeBreakProperties) .break_words(false); wrap(text, options) .into_iter() .map(|segment| segment.into_owned()) .collect() } #[derive(Debug, Clone)] struct CloudSetupOptions { provider: String, endpoint: Option, api_key: Option, force_cloud_base_url: bool, } impl CloudSetupOptions { fn parse(args: &[&str]) -> Result { let mut options = CloudSetupOptions { provider: "ollama_cloud".to_string(), endpoint: None, api_key: None, force_cloud_base_url: false, }; let mut iter = args.iter(); while let Some(arg) = iter.next() { match arg.trim() { "--provider" => { let value = iter.next().ok_or_else(|| { anyhow!("--provider expects a value (e.g. --provider ollama)") })?; options.provider = canonical_provider_name(value); } "--endpoint" => { let value = iter.next().ok_or_else(|| { anyhow!("--endpoint expects a URL (e.g. --endpoint https://ollama.com)") })?; options.endpoint = Some(value.trim().to_string()); } "--api-key" => { let value = iter.next().ok_or_else(|| { anyhow!("--api-key expects a value (e.g. --api-key sk-...)") })?; options.api_key = Some(value.trim().to_string()); } "--force-cloud-base-url" => { options.force_cloud_base_url = true; } flag if flag.starts_with("--") => { return Err(anyhow!("Unknown flag '{flag}' for :cloud setup")); } value => { if options.api_key.is_none() { options.api_key = Some(value.trim().to_string()); } else { return Err(anyhow!( "Unexpected argument '{value}'. Provide a single API key or use --api-key." )); } } } } if options.provider.trim().is_empty() { options.provider = "ollama_cloud".to_string(); } options.provider = canonical_provider_name(&options.provider); Ok(options) } } fn canonical_provider_name(provider: &str) -> String { let normalized = provider.trim().to_ascii_lowercase().replace('-', "_"); match normalized.as_str() { "" => "ollama_cloud".to_string(), "ollama" => "ollama_cloud".to_string(), "ollama_cloud" => "ollama_cloud".to_string(), value => value.to_string(), } } fn normalize_cloud_endpoint(endpoint: &str) -> String { let trimmed = endpoint.trim().trim_end_matches('/'); if trimmed.is_empty() { DEFAULT_CLOUD_ENDPOINT.to_string() } else { trimmed.to_string() } } #[cfg(test)] mod tests { use super::{ChatApp, ModelAvailabilityState, ModelScope, render_markdown_lines, wrap_unicode}; use crate::app::UiRuntime; use futures_util::{future, stream}; use owlen_core::{ Provider, Result as CoreResult, config::Config, consent::ConsentScope, llm::LlmProvider, session::{ControllerEvent, SessionController}, storage::StorageManager, types::{ChatRequest, ChatResponse, Message, ModelInfo, Role, ToolCall}, ui::NoOpUiController, }; use ratatui::style::Style; use ratatui::text::Line; use serde_json::json; use std::sync::Arc; use tempfile::tempdir; use tokio::sync::mpsc; fn lines_to_strings(lines: &[Line<'_>]) -> Vec { lines .iter() .map(|line| { line.spans .iter() .map(|span| span.content.as_ref()) .collect::() }) .collect() } #[test] fn render_markdown_table_draws_grid_when_width_allows() { let lines = render_markdown_lines( "| Name | Role |\n| --- | --- |\n| Alice | Developer |\n", "", 60, Style::default(), ); let rendered = lines_to_strings(&lines); assert!( rendered.iter().any(|line| line.contains("Table (1 row):")), "summary line should mention row count" ); assert!( rendered.iter().any(|line| line.contains('┌')), "grid border should be present when width permits" ); assert!( rendered.iter().any(|line| line.contains("Alice")), "table rows should include cell content" ); } #[test] fn render_markdown_table_falls_back_when_narrow() { let lines = render_markdown_lines( "| Name | Role |\n| --- | --- |\n| Alice | Developer |\n", "", 12, Style::default(), ); let rendered = lines_to_strings(&lines); assert!( rendered.iter().any(|line| line.contains("Name:")), "stacked fallback should label headers inline" ); assert!( rendered.iter().all(|line| !line.contains('┌')), "narrow layout should avoid grid borders" ); } #[test] fn wrap_unicode_respects_cjk_display_width() { let wrapped = wrap_unicode("你好世界", 4); assert_eq!(wrapped, vec!["你好".to_string(), "世界".to_string()]); } #[test] fn wrap_unicode_handles_emoji_graphemes() { let wrapped = wrap_unicode("🙂🙂🙂", 4); assert_eq!(wrapped, vec!["🙂🙂".to_string(), "🙂".to_string()]); } #[test] fn wrap_unicode_zero_width_returns_empty() { let wrapped = wrap_unicode("hello", 0); assert!(wrapped.is_empty()); } #[test] fn extract_scope_status_includes_extended_metadata() { let models = vec![ModelInfo { id: "demo".to_string(), name: "Demo".to_string(), description: None, provider: "demo".to_string(), context_window: None, capabilities: vec![ "scope:local".to_string(), "scope-status:local:available".to_string(), "scope-status-age:local:30".to_string(), "scope-status-success-age:local:5".to_string(), "scope-status-stale:local:1".to_string(), "scope-status-message:local:Cached copy".to_string(), ], supports_tools: false, }]; let statuses = ChatApp::extract_scope_status(&models); let entry = statuses.get(&ModelScope::Local).expect("local scope entry"); assert_eq!(entry.state, ModelAvailabilityState::Available); assert_eq!(entry.last_checked_secs, Some(30)); assert_eq!(entry.last_success_secs, Some(5)); assert!(entry.is_stale); assert_eq!(entry.message.as_deref(), Some("Cached copy")); } struct StubProvider; impl LlmProvider for StubProvider { type Stream = stream::Iter>>; type ListModelsFuture<'a> = future::Ready>> where Self: 'a; type SendPromptFuture<'a> = future::Ready> where Self: 'a; type StreamPromptFuture<'a> = future::Ready> where Self: 'a; type HealthCheckFuture<'a> = future::Ready> where Self: 'a; fn name(&self) -> &str { "stub-provider" } fn list_models(&self) -> Self::ListModelsFuture<'_> { future::ready(Ok(vec![])) } fn send_prompt(&self, _request: ChatRequest) -> Self::SendPromptFuture<'_> { let response = ChatResponse { message: Message::assistant("stub response".to_string()), usage: None, is_streaming: false, is_final: true, }; future::ready(Ok(response)) } fn stream_prompt(&self, _request: ChatRequest) -> Self::StreamPromptFuture<'_> { let response = ChatResponse { message: Message::assistant("stub response".to_string()), usage: None, is_streaming: false, is_final: true, }; future::ready(Ok(stream::iter(vec![Ok(response)]))) } fn health_check(&self) -> Self::HealthCheckFuture<'_> { future::ready(Ok(())) } } #[tokio::test(flavor = "multi_thread")] async fn tool_consent_denied_generates_fallback_message() { let temp_dir = tempdir().expect("tempdir"); let storage_path = temp_dir.path().join("owlen-test.db"); let storage = Arc::new( StorageManager::with_database_path(storage_path) .await .expect("storage"), ); let mut config = Config::default(); config.privacy.encrypt_local_data = false; let provider: Arc = Arc::new(StubProvider); let ui = Arc::new(NoOpUiController); let (event_tx, controller_event_rx) = mpsc::unbounded_channel::(); let session = SessionController::new(provider, config, storage, ui, false, Some(event_tx)) .await .expect("session"); let (mut app, session_rx) = ChatApp::new(session, controller_event_rx) .await .expect("chat app"); // Session events are not needed for this test drop(session_rx); let tool_call = ToolCall { id: "call-1".to_string(), name: "file_delete".to_string(), arguments: json!({"path": "/tmp/example.txt"}), }; let message_id = app .controller .conversation_mut() .push_assistant_message("Preparing to modify files."); app.controller .conversation_mut() .set_tool_calls_on_message(message_id, vec![tool_call.clone()]) .expect("tool calls"); app.pending_tool_execution = Some((message_id, vec![tool_call.clone()])); app.controller.check_streaming_tool_calls(message_id); UiRuntime::poll_controller_events(&mut app).expect("poll controller events"); let consent_state = app .pending_consent .as_ref() .expect("pending consent") .clone(); assert_eq!(consent_state.tool_name, "file_delete"); let resolution = app .controller .resolve_tool_consent(consent_state.request_id, ConsentScope::Denied) .expect("resolution"); app.apply_tool_consent_resolution(resolution) .expect("apply resolution"); assert!(app.pending_consent.is_none()); assert!(app.pending_tool_execution.is_none()); assert!(app.status.to_lowercase().contains("consent denied")); let conversation = app.controller.conversation(); let last_message = conversation.messages.last().expect("last message"); assert_eq!(last_message.role, Role::Assistant); assert!( last_message.content.contains("consent was denied"), "fallback message should acknowledge denial" ); } } fn validate_relative_path(path: &Path, allow_nested: bool) -> Result<()> { if path.as_os_str().is_empty() { return Err(anyhow!("Path cannot be empty")); } if path.is_absolute() { return Err(anyhow!("Path must be relative to the workspace root")); } let mut normal_segments = 0usize; for component in path.components() { match component { Component::Normal(_) => { normal_segments += 1; } Component::CurDir => { return Err(anyhow!("Path cannot contain '.' segments")); } Component::ParentDir => { return Err(anyhow!("Path cannot contain '..' segments")); } Component::RootDir | Component::Prefix(_) => { return Err(anyhow!("Path must be relative to the workspace root")); } } } if !allow_nested && normal_segments > 1 { return Err(anyhow!("Name cannot include path separators")); } Ok(()) } 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()); } impl MessageState for ChatApp {} #[async_trait] impl UiRuntime for ChatApp { async fn handle_ui_event(&mut self, event: Event) -> Result { ChatApp::handle_event(self, event).await } async fn handle_session_event(&mut self, event: SessionEvent) -> Result<()> { ChatApp::handle_session_event(self, event).await } async fn process_pending_llm_request(&mut self) -> Result<()> { ChatApp::process_pending_llm_request(self).await } async fn process_pending_tool_execution(&mut self) -> Result<()> { ChatApp::process_pending_tool_execution(self).await } fn poll_controller_events(&mut self) -> Result<()> { loop { match self.controller_event_rx.try_recv() { Ok(event) => self.handle_controller_event(event)?, Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break, Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break, } } Ok(()) } fn advance_loading_animation(&mut self) { ChatApp::advance_loading_animation(self); } fn streaming_count(&self) -> usize { ChatApp::streaming_count(self) } }