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