use crate::{ components::{ Autocomplete, AutocompleteResult, ChatMessage, ChatPanel, CommandHelp, InputBox, ModelPicker, PermissionPopup, PickerResult, PlanPanel, ProviderTabs, StatusBar, TodoPanel, }, events::{handle_key_event, AppEvent}, layout::AppLayout, provider_manager::ProviderManager, theme::{Provider, Theme, VimMode}, }; use tools_plan::AccumulatedPlanStatus; use tools_todo::TodoList; use agent_core::{CheckpointManager, SessionHistory, SessionStats, ToolContext, execute_tool, get_tool_definitions}; use color_eyre::eyre::Result; use crossterm::{ event::{Event, EventStream, EnableMouseCapture, DisableMouseCapture, KeyCode}, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, ExecutableCommand, }; use futures::StreamExt; use llm_core::{ChatMessage as LLMChatMessage, ChatOptions, LlmProvider, ProviderType}; use permissions::{Action, PermissionDecision, PermissionManager, Tool as PermTool}; use ratatui::{ backend::CrosstermBackend, layout::Rect, style::Style, text::{Line, Span}, widgets::Paragraph, Terminal, }; use serde_json::Value; use std::{io::stdout, path::PathBuf, sync::Arc, time::SystemTime}; use tokio::sync::mpsc; /// Holds information about a pending tool execution #[allow(dead_code)] // Fields used for permission popup display struct PendingToolCall { tool_name: String, arguments: Value, perm_tool: PermTool, context: Option, } /// Provider mode - single client or multi-provider manager enum ProviderMode { /// Legacy single-provider mode Single(Arc), /// Multi-provider with switching support (owned) Multi(ProviderManager), /// Multi-provider with shared manager (for engine integration) Shared(Arc>), } use agent_core::messages::{Message, UserAction, AgentResponse}; use agent_core::state::{AppState as EngineState}; pub struct TuiApp { // UI components chat_panel: ChatPanel, input_box: InputBox, status_bar: StatusBar, todo_panel: TodoPanel, plan_panel: PlanPanel, permission_popup: Option, autocomplete: Autocomplete, command_help: CommandHelp, provider_tabs: ProviderTabs, model_picker: ModelPicker, theme: Theme, // Session state stats: SessionStats, history: SessionHistory, checkpoint_mgr: CheckpointManager, todo_list: TodoList, // Provider state provider_mode: ProviderMode, opts: ChatOptions, perms: PermissionManager, #[allow(dead_code)] // Reserved for tool execution context ctx: ToolContext, #[allow(dead_code)] settings: config_agent::Settings, // Engine integration engine_tx: Option>, shared_state: Option>>, engine_rx: Option>, // Runtime state running: bool, waiting_for_llm: bool, pending_tool: Option, permission_tx: Option>, vim_mode: VimMode, } impl TuiApp { /// Set the engine components pub fn set_engine( &mut self, tx: mpsc::Sender, state: Arc>, rx: mpsc::Receiver, ) { self.engine_tx = Some(tx); self.shared_state = Some(state); self.engine_rx = Some(rx); } /// Create a new TUI app with a single provider (legacy mode) pub fn new( client: Arc, opts: ChatOptions, perms: PermissionManager, settings: config_agent::Settings, ) -> Result { let theme = Theme::default(); let mode = perms.mode(); // Determine provider from client name let provider = match client.name() { "anthropic" => Provider::Claude, "openai" => Provider::OpenAI, _ => Provider::Ollama, }; Ok(Self { chat_panel: ChatPanel::new(theme.clone()), input_box: InputBox::new(theme.clone()), status_bar: StatusBar::new(opts.model.clone(), mode, theme.clone()), todo_panel: TodoPanel::new(theme.clone()), plan_panel: PlanPanel::new(theme.clone()), permission_popup: None, autocomplete: Autocomplete::new(theme.clone()), command_help: CommandHelp::new(theme.clone()), provider_tabs: ProviderTabs::with_provider(provider, theme.clone()), model_picker: ModelPicker::new(theme.clone()), theme, stats: SessionStats::new(), history: SessionHistory::new(), checkpoint_mgr: CheckpointManager::new(PathBuf::from(".owlen/checkpoints")), todo_list: TodoList::new(), provider_mode: ProviderMode::Single(client), opts, perms, ctx: ToolContext::new(), settings, engine_tx: None, shared_state: None, engine_rx: None, running: true, waiting_for_llm: false, pending_tool: None, permission_tx: None, vim_mode: VimMode::Insert, }) } /// Create a new TUI app with multi-provider support pub fn with_provider_manager( provider_manager: ProviderManager, perms: PermissionManager, settings: config_agent::Settings, ) -> Result { let theme = Theme::default(); let mode = perms.mode(); // Get initial provider and model let current_provider = provider_manager.current_provider_type(); let current_model = provider_manager.current_model().to_string(); let provider = match current_provider { ProviderType::Anthropic => Provider::Claude, ProviderType::OpenAI => Provider::OpenAI, ProviderType::Ollama => Provider::Ollama, }; let opts = ChatOptions::new(¤t_model); // Check if we're in plan mode from settings let is_plan_mode = settings.mode == "plan"; let mut status_bar = StatusBar::new(current_model, mode, theme.clone()); if is_plan_mode { status_bar.set_planning_mode(true); } Ok(Self { chat_panel: ChatPanel::new(theme.clone()), input_box: InputBox::new(theme.clone()), status_bar, todo_panel: TodoPanel::new(theme.clone()), plan_panel: PlanPanel::new(theme.clone()), permission_popup: None, autocomplete: Autocomplete::new(theme.clone()), command_help: CommandHelp::new(theme.clone()), provider_tabs: ProviderTabs::with_provider(provider, theme.clone()), model_picker: ModelPicker::new(theme.clone()), theme, stats: SessionStats::new(), history: SessionHistory::new(), checkpoint_mgr: CheckpointManager::new(PathBuf::from(".owlen/checkpoints")), todo_list: TodoList::new(), provider_mode: ProviderMode::Multi(provider_manager), opts, perms, ctx: ToolContext::new(), settings, engine_tx: None, shared_state: None, engine_rx: None, running: true, waiting_for_llm: false, pending_tool: None, permission_tx: None, vim_mode: VimMode::Insert, }) } /// Create a new TUI app with a shared ProviderManager (for engine integration) pub fn with_shared_provider_manager( provider_manager: Arc>, perms: PermissionManager, settings: config_agent::Settings, ) -> Result { let theme = Theme::default(); let mode = perms.mode(); // Get initial provider and model (need to block to access shared manager) let (current_provider, current_model) = { let guard = futures::executor::block_on(provider_manager.lock()); (guard.current_provider_type(), guard.current_model().to_string()) }; let provider = match current_provider { ProviderType::Anthropic => Provider::Claude, ProviderType::OpenAI => Provider::OpenAI, ProviderType::Ollama => Provider::Ollama, }; let opts = ChatOptions::new(¤t_model); // Check if we're in plan mode from settings let is_plan_mode = settings.mode == "plan"; let mut status_bar = StatusBar::new(current_model, mode, theme.clone()); if is_plan_mode { status_bar.set_planning_mode(true); } Ok(Self { chat_panel: ChatPanel::new(theme.clone()), input_box: InputBox::new(theme.clone()), status_bar, todo_panel: TodoPanel::new(theme.clone()), plan_panel: PlanPanel::new(theme.clone()), permission_popup: None, autocomplete: Autocomplete::new(theme.clone()), command_help: CommandHelp::new(theme.clone()), provider_tabs: ProviderTabs::with_provider(provider, theme.clone()), model_picker: ModelPicker::new(theme.clone()), theme, stats: SessionStats::new(), history: SessionHistory::new(), checkpoint_mgr: CheckpointManager::new(PathBuf::from(".owlen/checkpoints")), todo_list: TodoList::new(), provider_mode: ProviderMode::Shared(provider_manager), opts, perms, ctx: ToolContext::new(), settings, engine_tx: None, shared_state: None, engine_rx: None, running: true, waiting_for_llm: false, pending_tool: None, permission_tx: None, vim_mode: VimMode::Insert, }) } /// Get the current LLM provider client fn get_client(&mut self) -> Result> { match &mut self.provider_mode { ProviderMode::Single(client) => Ok(Arc::clone(client)), ProviderMode::Multi(manager) => manager .get_provider() .map_err(|e| color_eyre::eyre::eyre!("{}", e)), ProviderMode::Shared(manager) => { let mut guard = futures::executor::block_on(manager.lock()); guard.get_provider() .map_err(|e| color_eyre::eyre::eyre!("{}", e)) } } } /// Switch to a different provider (works in multi-provider and shared modes) fn switch_provider(&mut self, provider_type: ProviderType) -> Result<()> { // Helper to update UI after successful switch let update_ui = |s: &mut Self, model: String| { let provider = match provider_type { ProviderType::Anthropic => Provider::Claude, ProviderType::OpenAI => Provider::OpenAI, ProviderType::Ollama => Provider::Ollama, }; s.provider_tabs.set_active(provider); s.opts.model = model.clone(); s.status_bar = StatusBar::new(model.clone(), s.perms.mode(), s.theme.clone()); s.chat_panel.add_message(ChatMessage::System( format!("Switched to {} (model: {})", provider_type, model) )); }; match &mut self.provider_mode { ProviderMode::Multi(manager) => { match manager.switch_provider(provider_type) { Ok(_) => { let model = manager.current_model().to_string(); update_ui(self, model); Ok(()) } Err(e) => { self.chat_panel.add_message(ChatMessage::System( format!("Failed to switch provider: {}", e) )); Err(color_eyre::eyre::eyre!("{}", e)) } } } ProviderMode::Shared(manager) => { let mut guard = futures::executor::block_on(manager.lock()); match guard.switch_provider(provider_type) { Ok(_) => { let model = guard.current_model().to_string(); drop(guard); // Release lock before updating UI update_ui(self, model); Ok(()) } Err(e) => { self.chat_panel.add_message(ChatMessage::System( format!("Failed to switch provider: {}", e) )); Err(color_eyre::eyre::eyre!("{}", e)) } } } ProviderMode::Single(_) => { self.chat_panel.add_message(ChatMessage::System( "Provider switching requires multi-provider mode. Restart with 'owlen' to enable.".to_string() )); Ok(()) } } } fn set_theme(&mut self, theme: Theme) { self.theme = theme.clone(); self.chat_panel = ChatPanel::new(theme.clone()); self.input_box = InputBox::new(theme.clone()); self.status_bar = StatusBar::new(self.opts.model.clone(), self.perms.mode(), theme.clone()); self.todo_panel.set_theme(theme.clone()); self.plan_panel.set_theme(theme.clone()); self.autocomplete.set_theme(theme.clone()); self.command_help.set_theme(theme.clone()); self.provider_tabs.set_theme(theme.clone()); self.model_picker.set_theme(theme); } /// Open the model picker for the current provider async fn open_model_picker(&mut self) { match &self.provider_mode { ProviderMode::Multi(manager) => { let provider_type = manager.current_provider_type(); let current_model = manager.current_model().to_string(); // Show loading state immediately self.model_picker.show_loading(provider_type); // Fetch models from provider match manager.list_models_for_provider(provider_type).await { Ok(models) => { if models.is_empty() { self.model_picker.show_error("No models available".to_string()); } else { self.model_picker.show(models, &provider_type.to_string(), ¤t_model); } } Err(e) => { self.model_picker.show_error(e.to_string()); } } } ProviderMode::Shared(manager) => { let guard = manager.lock().await; let provider_type = guard.current_provider_type(); let current_model = guard.current_model().to_string(); // Show loading state immediately self.model_picker.show_loading(provider_type); // Fetch models from provider match guard.list_models_for_provider(provider_type).await { Ok(models) => { drop(guard); // Release lock before updating UI if models.is_empty() { self.model_picker.show_error("No models available".to_string()); } else { self.model_picker.show(models, &provider_type.to_string(), ¤t_model); } } Err(e) => { self.model_picker.show_error(e.to_string()); } } } ProviderMode::Single(_) => { self.chat_panel.add_message(ChatMessage::System( "Model picker requires multi-provider mode. Use [1][2][3] to switch providers first.".to_string() )); } } } /// Set the model for the current provider fn set_current_model(&mut self, model: String) { match &mut self.provider_mode { ProviderMode::Multi(manager) => { manager.set_current_model(model.clone()); self.opts.model = model.clone(); self.status_bar = StatusBar::new(model.clone(), self.perms.mode(), self.theme.clone()); self.chat_panel.add_message(ChatMessage::System( format!("Model changed to: {}", model) )); } ProviderMode::Shared(manager) => { let mut guard = futures::executor::block_on(manager.lock()); guard.set_current_model(model.clone()); drop(guard); self.opts.model = model.clone(); self.status_bar = StatusBar::new(model.clone(), self.perms.mode(), self.theme.clone()); self.chat_panel.add_message(ChatMessage::System( format!("Model changed to: {}", model) )); } ProviderMode::Single(_) => {} } } /// Show keyboard shortcuts help fn show_shortcuts_help(&mut self) { let shortcuts = r#" --- Keyboard Shortcuts --- Provider Switching (Normal mode or empty input): [1] [2] [3] Switch provider (Claude/Ollama/OpenAI) Tab Cycle through providers M Open model picker Chat Navigation (Normal mode or empty input): j / k Select next/prev message J / K Scroll chat down/up (3 lines) g / G Scroll to top/bottom Esc Clear selection Scrolling (works anytime): PageUp/Down Scroll page up/down Vim Modes: Esc Normal mode (navigation) i Insert mode (typing) : Command mode Input: Enter Send message Ctrl+c Quit Commands: /help, /model , /clear, /theme "#; self.chat_panel.add_message(ChatMessage::System(shortcuts.trim().to_string())); } /// Get the public todo list for external updates pub fn todo_list(&self) -> &TodoList { &self.todo_list } /// Get the current accumulated plan from shared state (if any) fn get_current_plan(&self) -> Option { if let Some(state) = &self.shared_state { if let Ok(guard) = state.try_lock() { return guard.accumulated_plan.clone(); } } None } /// Show the plan panel pub fn show_plan_panel(&mut self) { self.plan_panel.show(); } /// Hide the plan panel pub fn hide_plan_panel(&mut self) { self.plan_panel.hide(); } /// Render the header line: OWLEN left, model + vim mode right fn render_header(&self, frame: &mut ratatui::Frame, area: Rect) { let vim_indicator = self.vim_mode.indicator(&self.theme.symbols); // Calculate right side content let right_content = format!("{} {}", self.opts.model, vim_indicator); let right_len = right_content.len(); // Calculate padding let name = "OWLEN"; let padding = area.width.saturating_sub(name.len() as u16 + right_len as u16 + 2); let line = Line::from(vec![ Span::styled(" ", Style::default()), Span::styled(name, self.theme.header_accent), Span::raw(" ".repeat(padding as usize)), Span::styled(&self.opts.model, self.theme.status_dim), Span::styled(" ", Style::default()), Span::styled(vim_indicator, self.theme.header_accent), Span::styled(" ", Style::default()), ]); let paragraph = Paragraph::new(line); frame.render_widget(paragraph, area); } /// Render a horizontal rule divider fn render_divider(&self, frame: &mut ratatui::Frame, area: Rect) { let rule = self.theme.symbols.horizontal_rule.repeat(area.width as usize); let line = Line::from(Span::styled(rule, Style::default().fg(self.theme.palette.border))); let paragraph = Paragraph::new(line); frame.render_widget(paragraph, area); } /// Render empty state placeholder (centered) fn render_empty_state(&self, frame: &mut ratatui::Frame, area: Rect) { let message = "Start a conversation..."; let padding = area.width.saturating_sub(message.len() as u16) / 2; let vertical_center = area.height / 2; // Create centered area let centered_area = Rect { x: area.x, y: area.y + vertical_center, width: area.width, height: 1, }; let line = Line::from(vec![ Span::raw(" ".repeat(padding as usize)), Span::styled(message, self.theme.status_dim), ]); let paragraph = Paragraph::new(line); frame.render_widget(paragraph, centered_area); } /// Check if the chat panel is empty (no real messages) fn is_chat_empty(&self) -> bool { self.chat_panel.messages().is_empty() } pub async fn run(&mut self) -> Result<()> { // Setup terminal enable_raw_mode()?; stdout() .execute(EnterAlternateScreen)? .execute(EnableMouseCapture)?; let backend = CrosstermBackend::new(stdout()); let mut terminal = Terminal::new(backend)?; terminal.clear()?; // Create event channel let (event_tx, mut event_rx) = mpsc::unbounded_channel(); // Spawn terminal event listener let tx_clone = event_tx.clone(); tokio::spawn(async move { let mut reader = EventStream::new(); while let Some(event) = reader.next().await { match event { Ok(Event::Key(key)) => { if let Some(app_event) = handle_key_event(key) { let _ = tx_clone.send(app_event); } } Ok(Event::Mouse(mouse)) => { use crossterm::event::MouseEventKind; match mouse.kind { MouseEventKind::ScrollUp => { let _ = tx_clone.send(AppEvent::ScrollUp); } MouseEventKind::ScrollDown => { let _ = tx_clone.send(AppEvent::ScrollDown); } _ => {} } } Ok(Event::Resize(w, h)) => { let _ = tx_clone.send(AppEvent::Resize { width: w, height: h, }); } _ => {} } } }); // Spawn engine event listener if let Some(mut rx) = self.engine_rx.take() { let tx_clone = event_tx.clone(); tokio::spawn(async move { while let Some(msg) = rx.recv().await { let _ = tx_clone.send(AppEvent::EngineMessage(msg)); } }); } // Show first-run welcome message if this is the first time if config_agent::is_first_run() { self.chat_panel.add_message(ChatMessage::System( "Welcome to Owlen! 🦉\n\n\ You're starting with:\n\ • Provider: Ollama (local, free)\n\ • Model: qwen3:8b (tool-capable)\n\n\ Quick start:\n\ • [1][2][3] - Switch providers (Claude/Ollama/OpenAI)\n\ • [Tab] - Cycle through providers\n\ • [m] - Open model picker (in Normal mode)\n\ • [Esc] - Enter Normal mode\n\ • /help - Show all commands\n\n\ To authenticate with cloud providers:\n\ • Run: owlen login anthropic\n\ • Run: owlen login openai".to_string() )); } // Main event loop while self.running { // Render terminal.draw(|frame| { let size = frame.area(); let todo_height = self.todo_panel.min_height(); let layout = AppLayout::calculate_with_todo(size, todo_height); // Render header: OWLEN left, model + vim mode right self.render_header(frame, layout.header_area); // Render provider tabs self.provider_tabs.render(frame, layout.tabs_area); // Render top divider (horizontal rule) self.render_divider(frame, layout.top_divider); // Update scroll position before rendering self.chat_panel.update_scroll(layout.chat_area); // Render chat area or empty state if self.is_chat_empty() { self.render_empty_state(frame, layout.chat_area); } else { self.chat_panel.render(frame, layout.chat_area); } // Render todo panel if visible if todo_height > 0 { self.todo_panel.render(frame, layout.todo_area, &self.todo_list); } // Render bottom divider self.render_divider(frame, layout.bottom_divider); // Render input area self.input_box.render(frame, layout.input_area); // Render status bar self.status_bar.render(frame, layout.status_area); // Render overlays (in order of priority) // 1. Autocomplete dropdown (above input) if self.autocomplete.is_visible() { self.autocomplete.render(frame, layout.input_area); } // 2. Model picker (centered modal) if self.model_picker.is_visible() { self.model_picker.render(frame, size); } // 3. Plan panel (when accumulating or reviewing a plan) if self.plan_panel.is_visible() { let plan = self.get_current_plan(); // Calculate centered area for plan panel (60% width, 50% height) let plan_width = (size.width * 3 / 5).max(60).min(size.width - 4); let plan_height = (size.height / 2).max(15).min(size.height - 4); let plan_area = AppLayout::center_popup(size, plan_width, plan_height); self.plan_panel.render(frame, plan_area, plan.as_ref()); } // 4. Command help overlay (centered modal) if self.command_help.is_visible() { self.command_help.render(frame, size); } // 5. Permission popup (highest priority) if let Some(popup) = &self.permission_popup { popup.render(frame, size); } })?; // Handle events if let Ok(event) = event_rx.try_recv() { self.handle_event(event, &event_tx).await?; } // Small delay to prevent busy-waiting tokio::time::sleep(tokio::time::Duration::from_millis(16)).await; } // Cleanup terminal disable_raw_mode()?; stdout() .execute(LeaveAlternateScreen)? .execute(DisableMouseCapture)?; Ok(()) } async fn handle_event( &mut self, event: AppEvent, event_tx: &mpsc::UnboundedSender, ) -> Result<()> { match event { AppEvent::Input(key) => { // Handle overlays in priority order (highest first) // 1. Permission popup (highest priority) if let Some(popup) = &mut self.permission_popup { if let Some(option) = popup.handle_key(key) { use crate::components::PermissionOption; match option { PermissionOption::AllowOnce => { self.chat_panel.add_message(ChatMessage::System( "Permission granted once".to_string() )); if let Some(tx) = self.permission_tx.take() { let _ = tx.send(true); } if let Some(tx) = &self.engine_tx { let _ = tx.send(Message::UserAction(UserAction::PermissionResult(true))).await; } } PermissionOption::AlwaysAllow => { // Add rule to permission manager if let Some(pending) = &self.pending_tool { self.perms.add_rule( pending.perm_tool, pending.context.clone(), Action::Allow, ); self.chat_panel.add_message(ChatMessage::System( format!("Always allowed: {}", pending.tool_name) )); } if let Some(tx) = self.permission_tx.take() { let _ = tx.send(true); } if let Some(tx) = &self.engine_tx { let _ = tx.send(Message::UserAction(UserAction::PermissionResult(true))).await; } } PermissionOption::Deny => { self.chat_panel.add_message(ChatMessage::System( "Permission denied".to_string() )); if let Some(tx) = self.permission_tx.take() { let _ = tx.send(false); } if let Some(tx) = &self.engine_tx { let _ = tx.send(Message::UserAction(UserAction::PermissionResult(false))).await; } } PermissionOption::Explain => { // Show explanation if let Some(pending) = &self.pending_tool { let explanation = format!( "Tool '{}' requires permission. This operation will {}.", pending.tool_name, match pending.tool_name.as_str() { "read" => "read a file from disk", "write" => "write or overwrite a file", "edit" => "modify an existing file", "bash" => "execute a shell command", "grep" => "search for patterns in files", "glob" => "list files matching a pattern", _ => "perform an operation", } ); self.chat_panel.add_message(ChatMessage::System(explanation)); } // Don't close popup, let user choose again return Ok(()); } } self.permission_popup = None; self.pending_tool = None; self.status_bar.set_state(crate::components::AppState::Idle); self.status_bar.set_pending_permission(None); } return Ok(()); } // 2. Plan panel (when reviewing accumulated plan) if self.plan_panel.is_visible() { match key.code { // Navigation KeyCode::Char('j') | KeyCode::Down => { if let Some(plan) = self.get_current_plan() { self.plan_panel.select_next(plan.steps.len()); } } KeyCode::Char('k') | KeyCode::Up => { self.plan_panel.select_prev(); } // Toggle step approval KeyCode::Char(' ') => { if let Some(state) = &self.shared_state { if let Ok(mut guard) = state.try_lock() { if let Some(plan) = guard.current_plan_mut() { let idx = self.plan_panel.selected_index(); if let Some(step) = plan.steps.get_mut(idx) { step.approved = match step.approved { None => Some(true), Some(true) => Some(false), Some(false) => None, }; } } } } } // Finalize plan (stop accumulating, enter review) KeyCode::Char('f') | KeyCode::Char('F') => { if let Some(tx) = &self.engine_tx { let _ = tx.send(Message::UserAction(UserAction::FinalizePlan)).await; } } // Approve all pending KeyCode::Char('a') | KeyCode::Char('A') => { if let Some(state) = &self.shared_state { if let Ok(mut guard) = state.try_lock() { if let Some(plan) = guard.current_plan_mut() { for step in &mut plan.steps { if step.approved.is_none() { step.approved = Some(true); } } } } } } // Reject all pending KeyCode::Char('r') | KeyCode::Char('R') => { if let Some(state) = &self.shared_state { if let Ok(mut guard) = state.try_lock() { if let Some(plan) = guard.current_plan_mut() { for step in &mut plan.steps { if step.approved.is_none() { step.approved = Some(false); } } } } } } // Execute approved steps KeyCode::Enter => { if let Some(plan) = self.get_current_plan() { if plan.status == AccumulatedPlanStatus::Reviewing { // Collect approval decisions let approved: Vec<_> = plan.steps.iter() .filter(|s| s.approved == Some(true)) .map(|s| s.id.clone()) .collect(); let rejected: Vec<_> = plan.steps.iter() .filter(|s| s.approved == Some(false)) .map(|s| s.id.clone()) .collect(); let approval = tools_plan::PlanApproval { approved_ids: approved, rejected_ids: rejected, }; if let Some(tx) = &self.engine_tx { let _ = tx.send(Message::UserAction(UserAction::PlanApproval(approval))).await; } self.plan_panel.hide(); } } } // Save plan KeyCode::Char('s') | KeyCode::Char('S') => { if let Some(tx) = &self.engine_tx { // TODO: Prompt for plan name let name = format!("plan-{}", std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs()); let _ = tx.send(Message::UserAction(UserAction::SavePlan(name))).await; } } // Cancel/close KeyCode::Esc | KeyCode::Char('c') | KeyCode::Char('C') => { if let Some(plan) = self.get_current_plan() { if plan.status == AccumulatedPlanStatus::Accumulating { // Cancel the plan entirely if let Some(tx) = &self.engine_tx { let _ = tx.send(Message::UserAction(UserAction::CancelPlan)).await; } } } self.plan_panel.hide(); } _ => {} } return Ok(()); } // 3. Command help overlay if self.command_help.is_visible() { self.command_help.handle_key(key); return Ok(()); } // 4. Model picker if self.model_picker.is_visible() { let current_model = self.opts.model.clone(); match self.model_picker.handle_key(key, ¤t_model) { PickerResult::Selected(model) => { self.set_current_model(model); } PickerResult::Cancelled => { // Just closed, no action } PickerResult::Handled | PickerResult::NotHandled => { // Navigation or unhandled key } } return Ok(()); } // 4. Autocomplete dropdown if self.autocomplete.is_visible() { match self.autocomplete.handle_key(key) { AutocompleteResult::Confirmed(cmd) => { // Insert confirmed command into input box self.input_box.set_text(cmd); self.autocomplete.hide(); } AutocompleteResult::Cancelled => { self.autocomplete.hide(); } AutocompleteResult::Handled => { // Key was handled (navigation), do nothing } AutocompleteResult::NotHandled => { // Pass through to input box self.handle_input_key(key, event_tx).await?; } } return Ok(()); } // 4. Normal input handling self.handle_input_key(key, event_tx).await?; } AppEvent::ScrollUp => { self.chat_panel.scroll_up(3); } AppEvent::ScrollDown => { self.chat_panel.scroll_down(3); } AppEvent::UserMessage(message) => { self.chat_panel .add_message(ChatMessage::User(message.clone())); self.history.add_user_message(message); } AppEvent::StreamStart => { // Streaming started - indicator will show in chat panel self.waiting_for_llm = true; self.chat_panel.set_streaming(true); } AppEvent::LlmChunk(chunk) => { // APPEND to last assistant message (don't create new one each time) self.chat_panel.append_to_assistant(&chunk); } AppEvent::StreamEnd { response } => { // Streaming finished self.waiting_for_llm = false; self.chat_panel.set_streaming(false); self.status_bar.set_state(crate::components::AppState::Idle); self.history.add_assistant_message(response.clone()); // Update stats (rough estimate) let tokens = response.len() / 4; self.stats.record_message(tokens, std::time::Duration::from_secs(1)); } AppEvent::StreamError(error) => { // Streaming error self.waiting_for_llm = false; self.chat_panel.set_streaming(false); self.status_bar.set_state(crate::components::AppState::Idle); self.chat_panel.add_message(ChatMessage::System( format!("Error: {}", error) )); } AppEvent::ToolCall { name, args } => { self.chat_panel.add_message(ChatMessage::ToolCall { name: name.clone(), args: args.to_string(), }); self.status_bar.set_last_tool(name); self.stats.record_tool_call(); } AppEvent::ToolResult { success, output } => { self.chat_panel .add_message(ChatMessage::ToolResult { success, output }); } AppEvent::PermissionRequest { tool, context } => { self.permission_popup = Some(PermissionPopup::new(tool, context, self.theme.clone())); } AppEvent::StatusUpdate(stats) => { self.stats = stats.clone(); self.status_bar.update_stats(stats); } AppEvent::Resize { .. } => { // Terminal will automatically re-layout on next draw } AppEvent::ToggleTodo => { self.todo_panel.toggle(); } AppEvent::EngineMessage(msg) => { match msg { Message::AgentResponse(res) => { match res { AgentResponse::Token(t) => { // Map to LlmChunk if !self.waiting_for_llm { self.waiting_for_llm = true; self.chat_panel.set_streaming(true); } self.chat_panel.append_to_assistant(&t); } AgentResponse::Complete => { self.waiting_for_llm = false; self.chat_panel.set_streaming(false); self.status_bar.set_state(crate::components::AppState::Idle); } AgentResponse::Error(e) => { self.waiting_for_llm = false; self.chat_panel.set_streaming(false); self.status_bar.set_state(crate::components::AppState::Idle); self.chat_panel.add_message(ChatMessage::System(format!("Error: {}", e))); } AgentResponse::PermissionRequest { tool, context } => { self.permission_popup = Some(PermissionPopup::new(tool.clone(), context, self.theme.clone())); self.status_bar.set_state(crate::components::AppState::WaitingPermission); self.status_bar.set_pending_permission(Some(tool)); } AgentResponse::PlanStaging(staging) => { self.chat_panel.add_message(ChatMessage::System("--- PENDING PLAN ---".to_string())); for tc in &staging { self.chat_panel.add_message(ChatMessage::ToolCall { name: tc.name.clone(), args: tc.args.clone() }); } self.chat_panel.add_message(ChatMessage::System("Approve plan in status bar? (y/n)".to_string())); self.status_bar.set_state(crate::components::AppState::WaitingPermission); self.status_bar.set_pending_permission(Some("PLAN".to_string())); } AgentResponse::ToolCall { name, args } => { self.chat_panel.add_message(ChatMessage::ToolCall { name, args }); } // Plan mode responses AgentResponse::PlanStepAdded(step) => { let msg = format!( "[PLAN] Step {}: {} ({})", step.turn, step.tool, if step.args.is_object() { step.args.to_string().chars().take(50).collect::() } else { step.args.to_string() } ); self.chat_panel.add_message(ChatMessage::System(msg)); // Auto-show plan panel when steps are being accumulated if !self.plan_panel.is_visible() { self.plan_panel.show(); } } AgentResponse::PlanComplete { total_steps, status: _ } => { self.chat_panel.add_message(ChatMessage::System( format!("--- PLAN COMPLETE ({} steps) ---", total_steps) )); self.chat_panel.add_message(ChatMessage::System( "Press [Enter] to execute or [Esc] to cancel".to_string() )); // Show plan panel for review self.plan_panel.show(); self.plan_panel.reset_selection(); self.status_bar.set_state(crate::components::AppState::WaitingPermission); self.status_bar.set_pending_permission(Some("PLAN REVIEW".to_string())); } AgentResponse::PlanExecuting { step_id: _, step_index, total_steps } => { self.chat_panel.add_message(ChatMessage::System( format!("Executing step {}/{}", step_index + 1, total_steps) )); } AgentResponse::PlanExecutionComplete { executed, skipped } => { self.chat_panel.add_message(ChatMessage::System( format!("Plan execution complete: {} executed, {} skipped", executed, skipped) )); // Hide plan panel after execution self.plan_panel.hide(); self.status_bar.set_state(crate::components::AppState::Idle); self.status_bar.set_pending_permission(None); } } } Message::System(sys) => { match sys { agent_core::messages::SystemNotification::Warning(w) => { self.chat_panel.add_message(ChatMessage::System(format!("Warning: {}", w))); } agent_core::messages::SystemNotification::StateUpdate(_s) => { // Handle state updates if needed } agent_core::messages::SystemNotification::PlanSaved { id, path } => { self.chat_panel.add_message(ChatMessage::System( format!("Plan saved: {} -> {}", id, path) )); } agent_core::messages::SystemNotification::PlanLoaded { id, name, steps } => { let name_str = name.unwrap_or_else(|| "(unnamed)".to_string()); self.chat_panel.add_message(ChatMessage::System( format!("Plan loaded: {} '{}' ({} steps)", id, name_str, steps) )); } } } _ => {} } } AppEvent::SwitchProvider(provider_type) => { let _ = self.switch_provider(provider_type); } AppEvent::CycleProvider => { // Get current provider type and cycle to next let current = match self.provider_tabs.active() { Provider::Claude => ProviderType::Anthropic, Provider::Ollama => ProviderType::Ollama, Provider::OpenAI => ProviderType::OpenAI, }; let next = match current { ProviderType::Anthropic => ProviderType::Ollama, ProviderType::Ollama => ProviderType::OpenAI, ProviderType::OpenAI => ProviderType::Anthropic, }; let _ = self.switch_provider(next); } AppEvent::OpenModelPicker => { self.open_model_picker().await; } AppEvent::Quit => { self.running = false; } } Ok(()) } /// Handle input keys with autocomplete integration async fn handle_input_key( &mut self, key: crossterm::event::KeyEvent, event_tx: &mpsc::UnboundedSender, ) -> Result<()> { use crate::components::InputEvent; // Global navigation keys that work in any mode match key.code { // PageUp - Scroll chat up one page (always works) KeyCode::PageUp => { self.chat_panel.page_up(20); return Ok(()); } // PageDown - Scroll chat down one page (always works) KeyCode::PageDown => { self.chat_panel.page_down(20); return Ok(()); } _ => {} } // Check for provider switching keys when input is empty or in Normal mode let input_empty = self.input_box.text().is_empty(); if input_empty || self.vim_mode == VimMode::Normal { match key.code { // [1] - Switch to Claude (Anthropic) KeyCode::Char('1') => { let _ = event_tx.send(AppEvent::SwitchProvider(ProviderType::Anthropic)); return Ok(()); } // [2] - Switch to Ollama KeyCode::Char('2') => { let _ = event_tx.send(AppEvent::SwitchProvider(ProviderType::Ollama)); return Ok(()); } // [3] - Switch to OpenAI KeyCode::Char('3') => { let _ = event_tx.send(AppEvent::SwitchProvider(ProviderType::OpenAI)); return Ok(()); } // Tab - Cycle providers KeyCode::Tab => { let _ = event_tx.send(AppEvent::CycleProvider); return Ok(()); } // '?' - Show shortcuts help KeyCode::Char('?') => { self.show_shortcuts_help(); return Ok(()); } // 'M' (Shift+m) - Open model picker KeyCode::Char('M') => { let _ = event_tx.send(AppEvent::OpenModelPicker); return Ok(()); } // 'j' - Navigate to next message (focus) KeyCode::Char('j') => { self.chat_panel.focus_next(); return Ok(()); } // 'k' - Navigate to previous message (focus) KeyCode::Char('k') => { self.chat_panel.focus_previous(); return Ok(()); } // 'J' (Shift+j) - Scroll chat down KeyCode::Char('J') => { self.chat_panel.scroll_down(3); return Ok(()); } // 'K' (Shift+k) - Scroll chat up KeyCode::Char('K') => { self.chat_panel.scroll_up(3); return Ok(()); } // 'G' (Shift+g) - Scroll to bottom KeyCode::Char('G') => { self.chat_panel.scroll_to_bottom(); return Ok(()); } // 'g' - Scroll to top (vim-like gg, simplified to single g) KeyCode::Char('g') => { self.chat_panel.scroll_to_top(); return Ok(()); } // Esc also clears message focus KeyCode::Esc => { self.chat_panel.clear_focus(); // Don't return - let it also handle vim mode change } _ => {} } } // Handle the key in input box if let Some(event) = self.input_box.handle_key(key) { match event { InputEvent::Message(message) => { // Hide autocomplete before processing self.autocomplete.hide(); self.handle_user_message(message, event_tx).await?; } InputEvent::Command(cmd) => { // Commands from vim command mode (without /) self.autocomplete.hide(); self.handle_command(&format!("/{}", cmd))?; } InputEvent::ModeChange(mode) => { self.vim_mode = mode; self.status_bar.set_vim_mode(mode); } InputEvent::Cancel => { self.autocomplete.hide(); self.waiting_for_llm = false; } InputEvent::Expand => { // TODO: Expand to multiline input } } } // Check if we should show/update autocomplete let input = self.input_box.text(); if input.starts_with('/') { let query = &input[1..]; // Text after / if !self.autocomplete.is_visible() { self.autocomplete.show(); } self.autocomplete.update_filter(query); } else { // Hide autocomplete if input doesn't start with / if self.autocomplete.is_visible() { self.autocomplete.hide(); } } Ok(()) } async fn handle_user_message( &mut self, message: String, event_tx: &mpsc::UnboundedSender, ) -> Result<()> { // Handle slash commands if message.starts_with('/') { self.handle_command(&message)?; return Ok(()); } // Add user message to chat IMMEDIATELY so it shows before AI response self.chat_panel .add_message(ChatMessage::User(message.clone())); self.history.add_user_message(message.clone()); // Start streaming indicator self.waiting_for_llm = true; self.chat_panel.set_streaming(true); self.status_bar.set_state(crate::components::AppState::Streaming); let _ = event_tx.send(AppEvent::StreamStart); // Check if we have an engine connection - use it for proper agent loop if let Some(tx) = &self.engine_tx { let _ = tx.send(Message::UserAction(UserAction::Input(message.clone()))).await; } else { // Fallback to legacy background stream if no engine // Only get client when needed for legacy path let client = match self.get_client() { Ok(c) => c, Err(e) => { self.waiting_for_llm = false; self.chat_panel.set_streaming(false); self.status_bar.set_state(crate::components::AppState::Idle); self.chat_panel.add_message(ChatMessage::System( format!("Failed to get provider: {}", e) )); return Ok(()); } }; // Spawn streaming in background task let opts = self.opts.clone(); let tx = event_tx.clone(); let message_owned = message.clone(); tokio::spawn(async move { match Self::run_background_stream(client, opts, message_owned, tx.clone()).await { Ok(response) => { let _ = tx.send(AppEvent::StreamEnd { response }); } Err(e) => { let _ = tx.send(AppEvent::StreamError(e.to_string())); } } }); } Ok(()) } /// Run streaming in background, sending chunks through channel async fn run_background_stream( client: Arc, opts: ChatOptions, prompt: String, tx: mpsc::UnboundedSender, ) -> Result { let messages = vec![LLMChatMessage::user(&prompt)]; let tools = get_tool_definitions(); let mut stream = client .chat_stream(&messages, &opts, Some(&tools)) .await .map_err(|e| color_eyre::eyre::eyre!("LLM provider error: {}", e))?; let mut response_content = String::new(); while let Some(chunk) = stream.next().await { let chunk = chunk.map_err(|e| color_eyre::eyre::eyre!("Stream error: {}", e))?; if let Some(content) = chunk.content { response_content.push_str(&content); // Send chunk to UI for immediate display let _ = tx.send(AppEvent::LlmChunk(content)); } // TODO: Handle tool calls in background streaming // For now, tool calls are ignored in background mode } Ok(response_content) } /// Execute a tool with permission handling /// /// This method checks permissions and either: /// - Executes the tool immediately if allowed /// - Returns an error if denied by policy /// - Shows a permission popup and waits for user decision if permission is needed /// /// The async wait for user decision works correctly because: /// 1. The event loop continues running while we await the channel /// 2. Keyboard events are processed by the separate event listener task /// 3. When user responds to popup, the channel is signaled and we resume /// /// Returns Ok(result) if allowed and executed, Err if denied or failed #[allow(dead_code)] // Reserved for interactive tool permission flow async fn execute_tool_with_permission( &mut self, tool_name: &str, arguments: &Value, ) -> Result { // Map tool name to permission tool enum let perm_tool = match tool_name { "read" => PermTool::Read, "write" => PermTool::Write, "edit" => PermTool::Edit, "bash" => PermTool::Bash, "grep" => PermTool::Grep, "glob" => PermTool::Glob, _ => PermTool::Read, // Default fallback }; // Extract context from arguments let context = match tool_name { "read" | "write" | "edit" => arguments.get("path").and_then(|v| v.as_str()).map(String::from), "bash" => arguments.get("command").and_then(|v| v.as_str()).map(String::from), _ => None, }; // Check permission let decision = self.perms.check(perm_tool, context.as_deref()); match decision { PermissionDecision::Allow => { // Execute directly execute_tool(tool_name, arguments, &self.perms, &self.ctx).await } PermissionDecision::Deny => { Err(color_eyre::eyre::eyre!("Permission denied by policy")) } PermissionDecision::Ask => { // Create channel for response let (tx, rx) = tokio::sync::oneshot::channel(); self.permission_tx = Some(tx); // Store pending tool info self.pending_tool = Some(PendingToolCall { tool_name: tool_name.to_string(), arguments: arguments.clone(), perm_tool, context: context.clone(), }); // Show permission popup self.permission_popup = Some(PermissionPopup::new( tool_name.to_string(), context, self.theme.clone(), )); // Wait for user decision (with timeout) match tokio::time::timeout(std::time::Duration::from_secs(300), rx).await { Ok(Ok(true)) => { // Permission granted, execute tool execute_tool(tool_name, arguments, &self.perms, &self.ctx).await } Ok(Ok(false)) => { // Permission denied Err(color_eyre::eyre::eyre!("Permission denied by user")) } Ok(Err(_)) => { // Channel closed without response Err(color_eyre::eyre::eyre!("Permission request cancelled")) } Err(_) => { // Timeout self.permission_popup = None; self.pending_tool = None; Err(color_eyre::eyre::eyre!("Permission request timed out")) } } } } } #[allow(dead_code)] // Reserved for full agent loop integration async fn run_streaming_agent_loop(&mut self, user_prompt: &str) -> Result { let tools = get_tool_definitions(); let mut messages = vec![LLMChatMessage::user(user_prompt)]; let max_iterations = 10; let mut iteration = 0; let mut final_response = String::new(); // Get the current provider client let client = self.get_client()?; loop { iteration += 1; if iteration > max_iterations { self.chat_panel.add_message(ChatMessage::System( "⚠️ Max iterations reached".to_string() )); break; } // Call LLM with streaming using the LlmProvider trait let mut stream = client .chat_stream(&messages, &self.opts, Some(&tools)) .await .map_err(|e| color_eyre::eyre::eyre!("LLM provider error: {}", e))?; let mut response_content = String::new(); let mut accumulated_tool_calls: Vec = Vec::new(); // Collect the streamed response while let Some(chunk) = stream.next().await { let chunk = chunk.map_err(|e| color_eyre::eyre::eyre!("Stream error: {}", e))?; if let Some(content) = chunk.content { response_content.push_str(&content); // Stream chunks to UI - append to last assistant message self.chat_panel.append_to_assistant(&content); } // Accumulate tool calls from deltas if let Some(deltas) = chunk.tool_calls { for delta in deltas { // Ensure the accumulated_tool_calls vec is large enough while accumulated_tool_calls.len() <= delta.index { accumulated_tool_calls.push(llm_core::ToolCall { id: String::new(), call_type: "function".to_string(), function: llm_core::FunctionCall { name: String::new(), arguments: serde_json::Value::Null, }, }); } let tool_call = &mut accumulated_tool_calls[delta.index]; if let Some(id) = delta.id { tool_call.id = id; } if let Some(name) = delta.function_name { tool_call.function.name = name; } if let Some(args_delta) = delta.arguments_delta { // Accumulate the arguments string let current_args = if tool_call.function.arguments.is_null() { String::new() } else { tool_call.function.arguments.to_string() }; let new_args = current_args + &args_delta; // Try to parse as JSON, but keep as string if incomplete tool_call.function.arguments = serde_json::from_str(&new_args) .unwrap_or_else(|_| serde_json::Value::String(new_args)); } } } } drop(stream); // Save the response for final return if !response_content.is_empty() { final_response = response_content.clone(); } // Filter out incomplete tool calls and check if we have valid ones let valid_tool_calls: Vec<_> = accumulated_tool_calls .into_iter() .filter(|tc| !tc.id.is_empty() && !tc.function.name.is_empty()) .collect(); // Check if LLM wants to call tools if !valid_tool_calls.is_empty() { // Add assistant message with tool calls to conversation messages.push(LLMChatMessage { role: llm_core::Role::Assistant, content: if response_content.is_empty() { None } else { Some(response_content.clone()) }, tool_calls: Some(valid_tool_calls.clone()), tool_call_id: None, name: None, }); // Execute each tool call for call in valid_tool_calls { let tool_name = &call.function.name; let arguments = &call.function.arguments; // Show tool call in UI self.chat_panel.add_message(ChatMessage::ToolCall { name: tool_name.clone(), args: arguments.to_string(), }); self.stats.record_tool_call(); match self.execute_tool_with_permission(tool_name, arguments).await { Ok(result) => { // Show success in UI self.chat_panel.add_message(ChatMessage::ToolResult { success: true, output: result.clone(), }); // Add tool result to conversation messages.push(LLMChatMessage::tool_result(&call.id, result)); } Err(e) => { let error_msg = format!("Error: {}", e); // Show error in UI self.chat_panel.add_message(ChatMessage::ToolResult { success: false, output: error_msg.clone(), }); // Add error to conversation messages.push(LLMChatMessage::tool_result(&call.id, error_msg)); } } } // Continue loop to get next response continue; } // No tool calls, we're done break; } Ok(final_response) } fn handle_command(&mut self, command: &str) -> Result<()> { match command { "/help" | "/?" => { // Show command help overlay self.command_help.show(); } "/status" => { let elapsed = self.stats.start_time.elapsed().unwrap_or_default(); self.chat_panel.add_message(ChatMessage::System(format!( "Model: {} | Mode: {:?} | Messages: {} | Tools: {} | Uptime: {}", self.opts.model, self.perms.mode(), self.stats.total_messages, self.stats.total_tool_calls, SessionStats::format_duration(elapsed) ))); } "/permissions" => { self.chat_panel.add_message(ChatMessage::System(format!( "Permission mode: {:?}", self.perms.mode() ))); } "/cost" => { self.chat_panel.add_message(ChatMessage::System(format!( "Estimated tokens: ~{} | Total time: {} | Note: Ollama is free!", self.stats.estimated_tokens, SessionStats::format_duration(self.stats.total_duration) ))); } "/history" => { let count = self.history.user_prompts.len(); self.chat_panel.add_message(ChatMessage::System(format!( "Conversation has {} messages", count ))); } "/checkpoint" => { let checkpoint_id = format!( "checkpoint-{}", SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs() ); match self .checkpoint_mgr .save_checkpoint(checkpoint_id.clone(), self.stats.clone(), &self.history) { Ok(_) => { self.chat_panel.add_message(ChatMessage::System(format!( "💾 Checkpoint saved: {}", checkpoint_id ))); } Err(e) => { self.chat_panel.add_message(ChatMessage::System(format!( "❌ Failed to save checkpoint: {}", e ))); } } } "/checkpoints" => { match self.checkpoint_mgr.list_checkpoints() { Ok(checkpoints) => { if checkpoints.is_empty() { self.chat_panel .add_message(ChatMessage::System("No checkpoints saved yet".to_string())); } else { self.chat_panel.add_message(ChatMessage::System(format!( "Saved checkpoints: {}", checkpoints.join(", ") ))); } } Err(e) => { self.chat_panel.add_message(ChatMessage::System(format!( "❌ Failed to list checkpoints: {}", e ))); } } } "/clear" => { self.chat_panel.clear(); self.history.clear(); self.stats = SessionStats::new(); self.chat_panel .add_message(ChatMessage::System("Session cleared".to_string())); } "/compact" => { self.chat_panel.add_message(ChatMessage::System( "Context compaction not yet implemented".to_string() )); } "/plan" => { // Toggle plan panel visibility if self.plan_panel.is_visible() { self.plan_panel.hide(); } else if self.get_current_plan().is_some() { self.plan_panel.show(); } else { self.chat_panel.add_message(ChatMessage::System( "No active plan. Start planning mode with a prompt.".to_string() )); } } "/provider" => { // Show available providers self.chat_panel.add_message(ChatMessage::System( "Available providers:".to_string() )); self.chat_panel.add_message(ChatMessage::System( " • ollama - Local LLM (default)".to_string() )); self.chat_panel.add_message(ChatMessage::System( " • anthropic - Claude API (requires ANTHROPIC_API_KEY)".to_string() )); self.chat_panel.add_message(ChatMessage::System( " • openai - OpenAI API (requires OPENAI_API_KEY)".to_string() )); self.chat_panel.add_message(ChatMessage::System( "Use '/provider ' to switch".to_string() )); } cmd if cmd.starts_with("/provider ") => { let provider_name = cmd.strip_prefix("/provider ").unwrap().trim(); let provider_type = match provider_name { "ollama" => Some(ProviderType::Ollama), "anthropic" | "claude" => Some(ProviderType::Anthropic), "openai" | "gpt" => Some(ProviderType::OpenAI), _ => None, }; if let Some(pt) = provider_type { if let Err(e) = self.switch_provider(pt) { self.chat_panel.add_message(ChatMessage::System(format!( "Failed to switch provider: {}", e ))); } } else { self.chat_panel.add_message(ChatMessage::System(format!( "Unknown provider: {}. Available: ollama, anthropic, openai", provider_name ))); } } "/model" => { // Show current model self.chat_panel.add_message(ChatMessage::System(format!( "Current model: {}", self.opts.model ))); self.chat_panel.add_message(ChatMessage::System( "Use '/model ' to switch (e.g., /model llama3.2, /model qwen3:8b)".to_string() )); } cmd if cmd.starts_with("/model ") => { let model_name = cmd.strip_prefix("/model ").unwrap().trim(); if model_name.is_empty() { self.chat_panel.add_message(ChatMessage::System(format!( "Current model: {}", self.opts.model ))); } else { // Use set_current_model to update both TUI and shared ProviderManager self.set_current_model(model_name.to_string()); } } "/themes" => { self.chat_panel.add_message(ChatMessage::System( "Available themes:".to_string() )); self.chat_panel.add_message(ChatMessage::System( " • tokyo-night - Modern and vibrant (default)".to_string() )); self.chat_panel.add_message(ChatMessage::System( " • dracula - Classic dark theme".to_string() )); self.chat_panel.add_message(ChatMessage::System( " • catppuccin - Warm and cozy".to_string() )); self.chat_panel.add_message(ChatMessage::System( " • nord - Minimal and clean".to_string() )); self.chat_panel.add_message(ChatMessage::System( " • synthwave - Vibrant retro".to_string() )); self.chat_panel.add_message(ChatMessage::System( " • rose-pine - Elegant and muted".to_string() )); self.chat_panel.add_message(ChatMessage::System( " • midnight-ocean - Deep and serene".to_string() )); self.chat_panel.add_message(ChatMessage::System( "Use '/theme ' to switch themes".to_string() )); } "/exit" => { self.running = false; } cmd if cmd.starts_with("/theme ") => { let theme_name = cmd.strip_prefix("/theme ").unwrap().trim(); let new_theme = match theme_name { "tokyo-night" => Some(Theme::tokyo_night()), "dracula" => Some(Theme::dracula()), "catppuccin" => Some(Theme::catppuccin()), "nord" => Some(Theme::nord()), "synthwave" => Some(Theme::synthwave()), "rose-pine" => Some(Theme::rose_pine()), "midnight-ocean" => Some(Theme::midnight_ocean()), _ => None, }; if let Some(theme) = new_theme { self.set_theme(theme); self.chat_panel.add_message(ChatMessage::System( format!("🎨 Theme changed to: {}", theme_name) )); } else { self.chat_panel.add_message(ChatMessage::System( format!("❌ Unknown theme: {}. Use '/themes' to see available themes.", theme_name) )); } } cmd if cmd.starts_with("/rewind ") => { let checkpoint_id = cmd.strip_prefix("/rewind ").unwrap().trim(); match self.checkpoint_mgr.rewind_to(checkpoint_id) { Ok(restored_files) => { self.chat_panel.add_message(ChatMessage::System(format!( "⏪ Rewound to checkpoint: {} ({} files restored)", checkpoint_id, restored_files.len() ))); } Err(e) => { self.chat_panel.add_message(ChatMessage::System(format!( "❌ Failed to rewind: {}", e ))); } } } _ => { self.chat_panel.add_message(ChatMessage::System(format!( "❌ Unknown command: {}", command ))); } } Ok(()) } }