diff --git a/src/app.rs b/src/app.rs index dbe877ee..eaf7d82d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,3 +1,8 @@ +use crate::views::status_dialog::render_status_dialog; +use crate::views::variants_dialog::{ + handle_variants_dialog_key_event, handle_variants_dialog_mouse_event, render_variants_dialog, + VariantsDialogAction, +}; use ratatui::crossterm::event::{ self, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; @@ -109,8 +114,8 @@ use crate::views::{ AgentsDialogState, ChatState, ConnectDialogState, HomeState, JobsDialogState, McpDialogState, ModelsDialogState, MoveSessionDialogState, PermissionDialogState, ProviderOAuthFlowState, QuestionDialogState, RemoteDialogState, SessionRenameDialogState, SessionsDialogState, - StorageDialogState, SuggestionsPopupState, TerminalSessionDialogState, ThemesDialogState, - TitleDialogState, + StatusDialogState, StorageDialogState, SuggestionsPopupState, TerminalSessionDialogState, + ThemesDialogState, TitleDialogState, VariantsDialogState, }; use crate::{ @@ -225,6 +230,8 @@ pub enum OverlayFocus { None, AgentsDialog, ModelsDialog, + VariantsDialog, + StatusDialog, RefreshModelsDialog, ThemesDialog, ConnectDialog, @@ -835,6 +842,8 @@ pub struct App { pub suggestions_popup_state: SuggestionsPopupState, pub agents_dialog_state: AgentsDialogState, pub models_dialog_state: ModelsDialogState, + pub variants_dialog_state: VariantsDialogState, + pub status_dialog_state: StatusDialogState, pub themes_dialog_state: ThemesDialogState, themes_dialog_original_theme_index: usize, themes_dialog_original_dark_mode: bool, @@ -911,6 +920,9 @@ pub struct App { pending_editor_suspend: Option, pub websearch: crate::config::configuration::WebsearchConfig, pub mcp: crate::config::configuration::McpConfig, + mcp_manager: Option>>, + mcp_summary: crate::views::home::McpSummary, + mcp_server_views: Vec, pub config_raw_merged: serde_json::Value, custom_instructions: String, terminal_focused: bool, @@ -1021,6 +1033,8 @@ impl App { let suggestions_popup_state = init_suggestions_popup(popup); let agents_dialog_state = init_agents_dialog("Select agent", vec![]); let models_dialog_state = init_models_dialog("Models", vec![]); + let variants_dialog_state = VariantsDialogState::new(); + let status_dialog_state = StatusDialogState::default(); let themes_dialog_state = init_themes_dialog("Themes", vec![], false); let connect_dialog_state = init_connect_dialog(); let provider_oauth_flow_state = init_provider_oauth_flow(); @@ -1093,6 +1107,8 @@ impl App { suggestions_popup_state, agents_dialog_state, models_dialog_state, + variants_dialog_state, + status_dialog_state, themes_dialog_state, themes_dialog_original_theme_index: 0, themes_dialog_original_dark_mode: true, @@ -1162,6 +1178,9 @@ impl App { pending_editor_suspend: None, websearch: crate::config::configuration::WebsearchConfig::default(), mcp: crate::config::configuration::McpConfig::default(), + mcp_manager: None, + mcp_summary: crate::views::home::McpSummary::default(), + mcp_server_views: Vec::new(), config_raw_merged: serde_json::json!({}), custom_instructions: String::new(), terminal_focused: true, @@ -1224,14 +1243,8 @@ impl App { let loaded_config = crate::config::ConfigLoader::load()?; let mut mcp_config = loaded_config.merged_config.mcp.clone(); crate::remote_mcp::apply_mcp_overrides(&mut mcp_config, prefs_dao.as_ref()); - if !mcp_config.is_empty() { - let warm_cfg = mcp_config.clone(); - let warm_cwd = - std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); - let _ = tokio::spawn(async move { - let _ = crate::mcp::McpManager::ensure(warm_cfg, warm_cwd); - }); - } + let warm_cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + self.mcp_manager = Some(crate::mcp::McpManager::ensure(mcp_config.clone(), warm_cwd)); self.input .set_image_open_config(loaded_config.merged_config.images.clone()); if !loaded_config.diagnostics.info.is_empty() { @@ -1414,6 +1427,56 @@ impl App { Ok(()) } + fn open_variants_dialog(&mut self, args: &[String]) { + if !args.is_empty() { + push_toast(Toast::new( + "Usage: /variants", + ToastLevel::Error, + Some(std::time::Duration::from_secs(3)), + )); + return; + } + + let Some(capability) = + self.reasoning_capability_for_model(&self.provider_name, &self.model) + else { + push_toast(Toast::new( + "The active model has no variants", + ToastLevel::Info, + Some(std::time::Duration::from_secs(3)), + )); + return; + }; + if capability.values().is_empty() { + push_toast(Toast::new( + "The active model has no variants", + ToastLevel::Info, + Some(std::time::Duration::from_secs(3)), + )); + return; + } + + let selected = self.reasoning_effort_override_for_model(&self.provider_name, &self.model); + self.variants_dialog_state.show(&capability, selected); + self.overlay_focus = OverlayFocus::VariantsDialog; + } + + fn select_variant(&mut self) { + let Some(effort) = self.variants_dialog_state.selected_effort() else { + return; + }; + let provider_id = self.provider_name.clone(); + let model_id = self.model.clone(); + match self.set_reasoning_effort_override_for_model(provider_id, model_id, effort) { + Ok(()) => self.variants_dialog_state.dialog.hide(), + Err(error) => push_toast(Toast::new( + format!("Failed to save variant: {error}"), + ToastLevel::Error, + Some(std::time::Duration::from_secs(3)), + )), + } + } + fn play_sound_event(&self, event: crate::sound::SoundEvent) { self.play_sound_event_with_notification_detail(event, None); } @@ -2721,8 +2784,8 @@ impl App { model_id: &str, ) -> Option { let capability = self.reasoning_capability_for_model(provider_id, model_id)?; - let requested = self.reasoning_effort_override_for_model(provider_id, model_id)?; - let resolved = capability.resolve(Some(requested))?; + let requested = self.reasoning_effort_override_for_model(provider_id, model_id); + let resolved = capability.resolve(requested)?; if resolved == crate::model::reasoning::ReasoningEffort::None { return None; } @@ -2760,6 +2823,57 @@ impl App { .map(|effort| effort.as_str().to_string()) } + fn model_name_for_display(&self, provider_id: &str, model_id: &str) -> String { + self.discovery + .as_ref() + .and_then(|discovery| discovery.get_model_name(provider_id, model_id)) + .unwrap_or_else(|| model_id.to_string()) + } + + fn provider_name_for_display(&self, provider_id: &str) -> String { + self.discovery + .as_ref() + .and_then(|discovery| discovery.get_provider_name(provider_id)) + .unwrap_or_else(|| provider_id.to_string()) + } + + fn refresh_mcp_summary(&mut self) { + let Some(manager) = self.mcp_manager.as_ref() else { + self.mcp_summary = crate::views::home::McpSummary { + connected: 0, + enabled: 0, + has_error: false, + }; + return; + }; + let Ok(manager) = manager.try_lock() else { + return; + }; + let views = manager.views(); + let enabled = views.iter().filter(|server| server.enabled).count(); + self.mcp_summary = crate::views::home::McpSummary { + connected: views + .iter() + .filter(|server| server.enabled && server.status == "connected") + .count(), + enabled, + has_error: views + .iter() + .any(|server| server.enabled && server.status == "failed"), + }; + self.mcp_server_views = views; + } + + fn open_status_dialog(&mut self, args: &[String]) { + if !args.is_empty() { + push_toast(Toast::new("Usage: /status", ToastLevel::Warning, None)); + return; + } + self.refresh_mcp_summary(); + self.status_dialog_state.show(self.mcp_server_views.clone()); + self.overlay_focus = OverlayFocus::StatusDialog; + } + fn cycle_reasoning_effort_for_model( &mut self, provider_id: String, @@ -4086,6 +4200,23 @@ impl App { } true } + OverlayFocus::VariantsDialog => { + let action = handle_variants_dialog_key_event(&mut self.variants_dialog_state, key); + if matches!(action, VariantsDialogAction::Select) { + self.select_variant(); + } + if !self.variants_dialog_state.dialog.is_visible() { + self.overlay_focus = OverlayFocus::None; + } + true + } + OverlayFocus::StatusDialog => { + if matches!(key.code, KeyCode::Esc | KeyCode::Enter) { + self.status_dialog_state.hide(); + self.overlay_focus = OverlayFocus::None; + } + true + } OverlayFocus::StorageDialog => { let action = handle_storage_dialog_key_event(&mut self.storage_dialog_state, key); self.handle_storage_dialog_action(action); @@ -4819,6 +4950,17 @@ impl App { return; } + if self.overlay_focus == OverlayFocus::StatusDialog + && matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) + { + let position = ratatui::layout::Position::new(mouse.column, mouse.row); + if !self.status_dialog_state.contains(position) { + self.status_dialog_state.hide(); + self.overlay_focus = OverlayFocus::None; + } + return; + } + // Bottom-right jobs chip → open jobs dialog. if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) && mouse.modifiers.is_empty() @@ -4938,6 +5080,14 @@ impl App { if !self.models_dialog_state.dialog.is_visible() { self.overlay_focus = OverlayFocus::None; } + } else if self.overlay_focus == OverlayFocus::VariantsDialog { + let action = handle_variants_dialog_mouse_event(&mut self.variants_dialog_state, mouse); + if matches!(action, VariantsDialogAction::Select) { + self.select_variant(); + } + if !self.variants_dialog_state.dialog.is_visible() { + self.overlay_focus = OverlayFocus::None; + } } else if self.overlay_focus == OverlayFocus::PermissionDialog { let action = handle_permission_dialog_mouse_event(&mut self.permission_dialog_state, mouse); @@ -5536,6 +5686,19 @@ impl App { .join(""), ); } + (_, OverlayFocus::VariantsDialog) => { + self.variants_dialog_state + .dialog + .search_textarea + .insert_str(&text); + self.variants_dialog_state.dialog.set_search_query( + self.variants_dialog_state + .dialog + .search_textarea + .lines() + .join(""), + ); + } (_, OverlayFocus::ThemesDialog) => { self.themes_dialog_state .dialog @@ -6478,6 +6641,14 @@ impl App { if self.start_models_command(&mut parsed) { return; } + if parsed.name == "variants" { + self.open_variants_dialog(&parsed.args); + return; + } + if parsed.name == "status" { + self.open_status_dialog(&parsed.args); + return; + } if parsed.name == "copy" && self.base_focus == BaseFocus::Chat { self.open_copy_actions_dialog(); return; @@ -6718,6 +6889,14 @@ impl App { if self.start_models_command(&mut parsed) { return; } + if parsed.name == "variants" { + self.open_variants_dialog(&parsed.args); + return; + } + if parsed.name == "status" { + self.open_status_dialog(&parsed.args); + return; + } if parsed.name == "copy" && self.base_focus == BaseFocus::Chat { self.open_copy_actions_dialog(); return; @@ -11328,8 +11507,12 @@ impl App { } let status_cwd = self.active_workspace_path(); let branch = self.current_git_branch(&status_cwd); - let usage_text = &self.cached_usage_text; let reasoning_effort = self.active_reasoning_effort_label(); + self.refresh_mcp_summary(); + let mcp_summary = self.mcp_summary; + let model_name = self.model_name_for_display(&self.provider_name, &self.model); + let provider_name = self.provider_name_for_display(&self.provider_name); + let usage_text = &self.cached_usage_text; match self.base_focus { BaseFocus::Home => { @@ -11341,11 +11524,12 @@ impl App { status_cwd.clone(), branch.clone(), self.agent.clone(), - self.model.clone(), - self.provider_name.clone(), + model_name, + provider_name, reasoning_effort.clone(), + mcp_summary, &colors, - &usage_text, + usage_text, ); if is_suggestions_visible(&self.suggestions_popup_state) @@ -11404,7 +11588,7 @@ impl App { is_compacting, esc_cancel_primed, retry_status.as_ref(), - &usage_text, + usage_text, subagent_tabs, &queued_messages, &mut self.find_bar, @@ -11452,6 +11636,17 @@ impl App { ); } + if self.overlay_focus == OverlayFocus::VariantsDialog + && self.variants_dialog_state.dialog.is_visible() + { + render_variants_dialog(f, &mut self.variants_dialog_state, size, colors); + } + + if self.overlay_focus == OverlayFocus::StatusDialog && self.status_dialog_state.is_visible() + { + render_status_dialog(f, &mut self.status_dialog_state, size, &colors); + } + if self.overlay_focus == OverlayFocus::RefreshModelsDialog { crate::views::models_dialog::render_refresh_models_dialog( f, @@ -12036,6 +12231,8 @@ mod tests { suggestions_popup_state: init_suggestions_popup(Popup::new()), agents_dialog_state: init_agents_dialog("Select agent", vec![]), models_dialog_state: init_models_dialog("Models", vec![]), + variants_dialog_state: VariantsDialogState::new(), + status_dialog_state: StatusDialogState::default(), themes_dialog_state: init_themes_dialog("Themes", vec![], false), themes_dialog_original_theme_index: 0, themes_dialog_original_dark_mode: true, @@ -12105,6 +12302,9 @@ mod tests { pending_editor_suspend: None, websearch: crate::config::configuration::WebsearchConfig::default(), mcp: crate::config::configuration::McpConfig::default(), + mcp_manager: None, + mcp_summary: crate::views::home::McpSummary::default(), + mcp_server_views: Vec::new(), config_raw_merged: serde_json::json!({}), custom_instructions: String::new(), terminal_focused: true, @@ -12362,6 +12562,36 @@ mod tests { assert!(app.agents_dialog_state.dialog.is_visible()); } + #[tokio::test(flavor = "multi_thread")] + async fn command_palette_opens_status_dialog() { + let mut app = test_app(); + + app.handle_command_palette_action(CommandPaletteAction::RunCommand("status".to_string())); + + assert_eq!(app.overlay_focus, OverlayFocus::StatusDialog); + assert!(app.status_dialog_state.is_visible()); + assert_eq!(app.base_focus, BaseFocus::Home); + } + + #[tokio::test(flavor = "multi_thread")] + async fn command_palette_opens_variants_dialog() { + let mut app = test_app(); + app.provider_name = "openai".to_string(); + app.model = "gpt-5".to_string(); + app.model_reasoning_options.insert( + (app.provider_name.clone(), app.model.clone()), + vec![crate::model::reasoning::ReasoningOption { + kind: "effort".to_string(), + values: vec!["low".to_string(), "medium".to_string(), "high".to_string()], + }], + ); + + app.handle_command_palette_action(CommandPaletteAction::RunCommand("variants".to_string())); + + assert_eq!(app.overlay_focus, OverlayFocus::VariantsDialog); + assert!(app.variants_dialog_state.dialog.is_visible()); + } + #[test] fn agent_dialog_includes_config_all_mode_agents() { let mut app = test_app(); @@ -12454,6 +12684,26 @@ mod tests { ); } + #[test] + fn reasoning_capable_model_uses_catalog_default_without_override() { + let mut app = test_app(); + app.provider_name = "openai".to_string(); + app.model = "gpt-5".to_string(); + app.model_reasoning_options.insert( + (app.provider_name.clone(), app.model.clone()), + vec![crate::model::reasoning::ReasoningOption { + kind: "effort".to_string(), + values: vec!["low".to_string(), "medium".to_string(), "high".to_string()], + }], + ); + + assert!(app.reasoning_efforts.is_empty()); + assert_eq!( + app.active_primary_agent_reasoning_effort(), + Some(crate::model::reasoning::ReasoningEffort::Medium) + ); + } + #[test] fn tab_cycles_through_config_primary_agents() { let mut app = test_app(); diff --git a/src/command/handlers.rs b/src/command/handlers.rs index 12bde08f..f136b295 100644 --- a/src/command/handlers.rs +++ b/src/command/handlers.rs @@ -12,6 +12,21 @@ pub fn handle_exit<'a>( Box::pin(async { CommandResult::Success("Exiting...".to_string()) }) } +pub fn handle_status<'a>( + parsed: &'a ParsedCommand, + _sm: &'a mut SessionManager, +) -> Pin + Send + 'a>> { + Box::pin(async move { + if !parsed.args.is_empty() { + return CommandResult::Error( + "This command only opens the status dialog. Usage: /status".to_string(), + ); + } + + CommandResult::Success(String::new()) + }) +} + pub fn handle_title<'a>( parsed: &'a ParsedCommand, _sm: &'a mut SessionManager, @@ -248,6 +263,21 @@ pub fn handle_models<'a>( Box::pin(async move { load_models(parsed).await }) } +pub fn handle_variants<'a>( + parsed: &'a ParsedCommand, + _sm: &'a mut SessionManager, +) -> Pin + Send + 'a>> { + let args = parsed.args.clone(); + Box::pin(async move { + if !args.is_empty() { + return CommandResult::Error( + "This command only opens the variants dialog. Usage: /variants".to_string(), + ); + } + CommandResult::Success(String::new()) + }) +} + pub async fn load_models(parsed: ParsedCommand) -> CommandResult { use crate::command::registry::DialogItem; use crate::model::discovery::Discovery; @@ -936,6 +966,22 @@ pub fn register_all_commands(registry: &mut Registry) { chat_only: false, }); + registry.register(Command { + name: "variants".to_string(), + description: "Switch model variant".to_string(), + handler: handle_variants, + hidden_tokens: vec!["reasoning effort".to_string()], + chat_only: false, + }); + + registry.register(Command { + name: "status".to_string(), + description: "Show status".to_string(), + handler: handle_status, + hidden_tokens: Vec::new(), + chat_only: false, + }); + registry.register(Command { name: "agents".to_string(), description: "Switch agent".to_string(), @@ -1387,7 +1433,7 @@ mod tests { async fn test_registry_has_all_commands() { let registry = create_registry(); let names = registry.get_command_names(); - assert_eq!(names.len(), 20); + assert_eq!(names.len(), 22); assert!(names.contains(&"exit".to_string())); assert!(names.contains(&"sessions".to_string())); assert!(names.contains(&"new".to_string())); @@ -1406,6 +1452,8 @@ mod tests { assert!(names.contains(&"skills".to_string())); assert!(names.contains(&"mcp".to_string())); assert!(names.contains(&"title".to_string())); + assert!(names.contains(&"variants".to_string())); + assert!(names.contains(&"status".to_string())); assert!(registry.is_chat_only("compact")); assert!(registry.is_chat_only("fork")); assert!(registry.is_chat_only("move")); @@ -1429,6 +1477,42 @@ mod tests { assert_eq!(result, CommandResult::Success("Exiting...".to_string())); } + #[tokio::test] + async fn test_handle_variants() { + let registry = create_registry(); + let parsed = ParsedCommand { + name: "variants".to_string(), + args: vec![], + raw: "/variants".to_string(), + prefs_data: None, + active_model_id: None, + }; + let mut session_manager = SessionManager::new(); + + assert!(matches!( + registry.execute(&parsed, &mut session_manager).await, + CommandResult::Success(message) if message.is_empty() + )); + } + + #[tokio::test] + async fn test_handle_status() { + let registry = create_registry(); + let parsed = ParsedCommand { + name: "status".to_string(), + args: vec![], + raw: "/status".to_string(), + prefs_data: None, + active_model_id: None, + }; + let mut session_manager = SessionManager::new(); + + assert!(matches!( + registry.execute(&parsed, &mut session_manager).await, + CommandResult::Success(message) if message.is_empty() + )); + } + #[tokio::test] async fn test_execute_unknown_command() { let registry = create_registry(); diff --git a/src/model/discovery.rs b/src/model/discovery.rs index d164a4f2..193d7969 100644 --- a/src/model/discovery.rs +++ b/src/model/discovery.rs @@ -784,13 +784,54 @@ impl Discovery { model.limit.as_ref().map(|l| l.context) } + pub fn get_model_name(&self, provider_id: &str, model_id: &str) -> Option { + if let Some(name) = self + .custom_providers + .as_ref() + .and_then(|providers| providers.get(&provider_id.trim().to_ascii_lowercase())) + .and_then(|provider| provider.models.get(model_id)) + .and_then(|model| model.name.clone()) + { + return Some(name); + } + + let entry = self.load_cache_entry().ok()??; + let provider = entry.data.get(provider_id)?; + let model = provider.models.get(model_id)?; + Some(model.name.clone()) + } + + pub fn get_provider_name(&self, provider_id: &str) -> Option { + if let Some(name) = self + .custom_providers + .as_ref() + .and_then(|providers| providers.get(&provider_id.trim().to_ascii_lowercase())) + .and_then(|provider| provider.name.clone()) + { + return Some(name); + } + + let entry = self.load_cache_entry().ok()??; + entry + .data + .get(provider_id) + .map(|provider| provider.name.clone()) + } + pub fn get_model_reasoning_capability( &self, provider_id: &str, model_id: &str, ) -> Option { - let entry = self.load_cache_entry().ok()??; - let provider = entry.data.get(provider_id)?; + let mut providers = self + .load_cache_entry() + .ok() + .flatten() + .map(|entry| entry.data.clone()) + .unwrap_or_default(); + self.apply_custom_provider_overlays(&mut providers); + + let provider = providers.get(provider_id)?; let model = provider.models.get(model_id)?; let provider_npm = model .provider @@ -991,6 +1032,67 @@ mod tests { assert_eq!(models[0].id, "configured-model"); assert_eq!(models[0].name, "Configured Model"); assert!(models[0].attachment); + assert_eq!( + discovery.get_model_name("mygateway", "configured-model"), + Some("Configured Model".to_string()) + ); + assert_eq!( + discovery.get_provider_name("mygateway"), + Some("My Gateway".to_string()) + ); + } + + #[test] + fn custom_model_reasoning_capability_is_available_without_catalog_cache() { + let providers = HashMap::from([( + "clika".to_string(), + CustomProviderConfig { + name: Some("CliKA".to_string()), + npm: Some("@ai-sdk/openai-compatible".to_string()), + base_url: None, + api_key: None, + models: HashMap::from([( + "gpt-5.6-terra".to_string(), + CustomModelConfig { + name: Some("CliKA gpt-5.6-terra".to_string()), + context_window: None, + max_tokens: None, + attachment: None, + reasoning: Some(true), + reasoning_options: Some(vec![crate::model::reasoning::ReasoningOption { + kind: "effort".to_string(), + values: vec![ + "low".to_string(), + "medium".to_string(), + "high".to_string(), + ], + }]), + temperature: None, + tool_call: None, + modalities: None, + launch: false, + }, + )]), + }, + )]); + let discovery = Discovery::new_with_custom(Some(providers)).expect("discovery"); + + let capability = discovery + .get_model_reasoning_capability("clika", "gpt-5.6-terra") + .expect("reasoning capability"); + + assert_eq!( + capability.values(), + &[ + crate::model::reasoning::ReasoningEffort::Low, + crate::model::reasoning::ReasoningEffort::Medium, + crate::model::reasoning::ReasoningEffort::High, + ] + ); + assert_eq!( + capability.default_effort(), + Some(crate::model::reasoning::ReasoningEffort::Medium) + ); } #[test] diff --git a/src/ui/components/dialog.rs b/src/ui/components/dialog.rs index 5dcee88b..1592b876 100644 --- a/src/ui/components/dialog.rs +++ b/src/ui/components/dialog.rs @@ -90,6 +90,8 @@ pub struct Dialog { pub actions: Vec, bottom_gap_height: u16, pub position: DialogPosition, + max_height: Option, + search_visible: bool, pub pending_delete_id: Option, collapsible_groups: bool, collapsed_groups: HashSet, @@ -128,6 +130,8 @@ impl Dialog { actions: Vec::new(), bottom_gap_height: 1, position: DialogPosition::Center, + max_height: None, + search_visible: true, pending_delete_id: None, collapsible_groups: false, collapsed_groups: HashSet::new(), @@ -143,6 +147,27 @@ impl Dialog { self } + pub fn with_max_height(mut self, height: u16) -> Self { + self.max_height = Some(height.max(1)); + self + } + + pub fn with_search_visible(mut self, visible: bool) -> Self { + self.search_visible = visible; + if !visible { + self.search_query.clear(); + } + self + } + + fn search_area_height(&self) -> u16 { + if self.search_visible { + SEARCH_AREA_HEIGHT + } else { + 0 + } + } + pub fn with_collapsible_groups(mut self, enabled: bool) -> Self { self.collapsible_groups = enabled; if !enabled { @@ -969,14 +994,15 @@ impl Dialog { let footer_height = self.footer_height(); let total_fixed_height = - 1 + 1 + SEARCH_AREA_HEIGHT + self.bottom_gap_height + footer_height; + 1 + 1 + self.search_area_height() + self.bottom_gap_height + footer_height; let (_, padding_y) = self.content_padding(); let padding_total = padding_y * 2; match self.position { DialogPosition::Center => { + let dialog_height = self.max_height.unwrap_or(DIALOG_HEIGHT_CENTER); let list_area_height = - DIALOG_HEIGHT_CENTER.saturating_sub(total_fixed_height + padding_total); + dialog_height.saturating_sub(total_fixed_height + padding_total); list_area_height as usize } DialogPosition::Left | DialogPosition::Right => { @@ -1066,7 +1092,7 @@ impl Dialog { [ ratatui::layout::Constraint::Length(1), ratatui::layout::Constraint::Length(1), - ratatui::layout::Constraint::Length(SEARCH_AREA_HEIGHT), + ratatui::layout::Constraint::Length(self.search_area_height()), ratatui::layout::Constraint::Min(0), ratatui::layout::Constraint::Length(self.bottom_gap_height), ratatui::layout::Constraint::Length(self.footer_height()), @@ -1255,6 +1281,7 @@ impl Dialog { } KeyCode::Char('j') if event.modifiers == KeyModifiers::CONTROL => true, KeyCode::Char('c') if event.modifiers == KeyModifiers::CONTROL => false, + _ if !self.search_visible => false, _ => { let previous_query = self.search_query.clone(); input_textarea(&mut self.search_textarea, event); @@ -1585,7 +1612,9 @@ impl Dialog { match self.position { DialogPosition::Center => { let dialog_width = area.width.min(DIALOG_WIDTH_CENTER); - let dialog_height = area.height.min(DIALOG_HEIGHT_CENTER); + let dialog_height = area + .height + .min(self.max_height.unwrap_or(DIALOG_HEIGHT_CENTER)); self.dialog_area = Rect { x: (area.width - dialog_width) / 2, @@ -1659,7 +1688,9 @@ impl Dialog { .alignment(ratatui::layout::Alignment::Right); frame.render_widget(esc_paragraph, header_chunks[1]); - frame.render_widget(&self.search_textarea, chunks[2]); + if self.search_visible { + frame.render_widget(&self.search_textarea, chunks[2]); + } let mut content_lines = Vec::new(); let list_area_width = chunks[3].width.saturating_sub(2); // Subtract scrollbar width @@ -1886,6 +1917,8 @@ impl Clone for Dialog { actions: self.actions.clone(), bottom_gap_height: self.bottom_gap_height, position: self.position, + max_height: self.max_height, + search_visible: self.search_visible, pending_delete_id: self.pending_delete_id.clone(), collapsible_groups: self.collapsible_groups, collapsed_groups: self.collapsed_groups.clone(), diff --git a/src/views/command_palette.rs b/src/views/command_palette.rs index 741faba2..ea198f5a 100644 --- a/src/views/command_palette.rs +++ b/src/views/command_palette.rs @@ -291,6 +291,12 @@ fn core_palette_items( "Return to a blank home screen", ), ("models", "Change Model", "Model", "Choose the active model"), + ( + "variants", + "Switch model variant", + "Model", + "Choose reasoning effort for the active model", + ), ( "connect", "Connect Provider", @@ -321,6 +327,12 @@ fn core_palette_items( "Appearance", "Choose and reorder terminal title items", ), + ( + "status", + "Status", + "Application", + "Show MCP, formatter, and plugin status", + ), ("exit", "Quit Crabcode", "Application", "Exit the app"), ] { let Some(registered) = registry.get(command) else { @@ -447,11 +459,11 @@ fn core_palette_items( .unwrap_or(items.len()), app_action_item( "cycle-reasoning-effort", - "Cycle Reasoning Effort", + "Variant Cycle", "Model", - "Switch reasoning effort for the active model", + "Cycle reasoning effort for the active model", Some("ctrl+t"), - &[], + &["reasoning effort", "cycle reasoning effort"], ), ); @@ -828,6 +840,62 @@ mod tests { ); } + #[test] + fn palette_includes_variants_and_status_commands() { + let mut registry = Registry::new(); + register_all_commands(&mut registry); + let mut state = init_command_palette(); + + state.refresh_items(®istry, false, true, false); + + let variants = state + .dialog + .items + .iter() + .find(|item| item.id == "variants") + .expect("variants should be listed"); + assert_eq!(variants.name, "Switch model variant"); + assert_eq!(variants.group, "Model"); + assert_eq!( + action_for_item(variants), + CommandPaletteAction::RunCommand("variants".to_string()) + ); + + let status = state + .dialog + .items + .iter() + .find(|item| item.id == "status") + .expect("status should be listed"); + assert_eq!(status.name, "Status"); + assert_eq!(status.group, "Application"); + assert_eq!( + action_for_item(status), + CommandPaletteAction::RunCommand("status".to_string()) + ); + } + + #[test] + fn palette_search_matches_reasoning_effort_for_variant_actions() { + let mut registry = Registry::new(); + register_all_commands(&mut registry); + let mut state = init_command_palette(); + + state.refresh_items(®istry, false, true, false); + state.dialog.set_search_query("reasoning effort"); + + let matches = state + .dialog + .filtered_items + .iter() + .flat_map(|(_, items)| items.iter()) + .map(|item| (item.id.as_str(), item.name.as_str())) + .collect::>(); + + assert!(matches.contains(&("variants", "Switch model variant"))); + assert!(matches.contains(&("cycle-reasoning-effort", "Variant Cycle"))); + } + #[test] fn palette_includes_mcp_dialog_action() { let mut registry = Registry::new(); diff --git a/src/views/home.rs b/src/views/home.rs index 3027303c..d8d8d713 100644 --- a/src/views/home.rs +++ b/src/views/home.rs @@ -15,6 +15,32 @@ use crate::ui::components::status_bar::StatusBar; const LOGO: &str = include_str!("../../crabcode-logo.txt"); const MASCOT: &str = include_str!("../../mascot.txt"); +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct McpSummary { + pub connected: usize, + pub enabled: usize, + pub has_error: bool, +} + +fn mcp_status(summary: McpSummary) -> Option<(&'static str, String)> { + if summary.enabled == 0 { + return None; + } + let indicator = if summary.has_error { + "●" + } else if summary.connected == summary.enabled { + "●" + } else { + "◐" + }; + let count = if summary.connected == summary.enabled { + summary.connected.to_string() + } else { + format!("{}/{}", summary.connected, summary.enabled) + }; + Some((indicator, format!(" {count} MCP"))) +} + #[derive(Debug, Clone)] pub struct HomeState { phase: u8, @@ -60,6 +86,7 @@ pub fn render_home( model: String, provider_name: String, reasoning_effort: Option, + mcp_summary: McpSummary, colors: &ThemeColors, usage_text: &str, ) { @@ -214,6 +241,8 @@ pub fn render_home( ); let help_text = vec![ + Span::styled("tab", Style::default().fg(colors.info)), + Span::raw(" agents "), Span::styled("ctrl+p", Style::default().fg(colors.info)), Span::raw(" commands"), ]; @@ -222,29 +251,43 @@ pub fn render_home( let available_width = home_chunks[2].width; let help_width = help_width.min(available_width); - let usage_width = if !usage_text.is_empty() { - (usage_text.len() as u16 + 2).min(available_width.saturating_sub(help_width)) - } else { - 0 - }; + let mut status_spans = Vec::new(); + if !usage_text.is_empty() { + status_spans.push(Span::styled( + usage_text.to_string(), + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + )); + } + if let Some((indicator, label)) = mcp_status(mcp_summary) { + if !status_spans.is_empty() { + status_spans.push(Span::raw(" ")); + } + let color = if mcp_summary.has_error { + colors.error + } else if mcp_summary.connected == mcp_summary.enabled { + colors.success + } else { + colors.warning + }; + status_spans.push(Span::styled(indicator, Style::default().fg(color))); + status_spans.push(Span::styled(label, Style::default().fg(colors.text))); + } + let status_line = Line::from(status_spans); + let status_width = (status_line.width() as u16).min(available_width.saturating_sub(help_width)); let status_chunks = Layout::default() .direction(Direction::Horizontal) .constraints([ - Constraint::Length(usage_width), + Constraint::Length(status_width), Constraint::Min(0), Constraint::Length(help_width), ]) .split(home_chunks[2]); - if !usage_text.is_empty() { - let usage = Paragraph::new(Line::from(vec![Span::styled( - usage_text, - Style::default() - .fg(colors.text_weak) - .add_modifier(Modifier::DIM), - )])); - f.render_widget(usage, status_chunks[0]); + if status_width > 0 { + f.render_widget(Paragraph::new(status_line), status_chunks[0]); } let help = Paragraph::new(help_line).alignment(Alignment::Right); @@ -259,3 +302,33 @@ pub fn render_home( let status_bar = StatusBar::new(version, cwd, branch, agent, model); status_bar.render(f, main_chunks[1], colors); } + +#[cfg(test)] +mod tests { + use super::{mcp_status, McpSummary}; + + #[test] + fn mcp_status_hides_when_no_servers_are_enabled() { + assert_eq!(mcp_status(McpSummary::default()), None); + } + + #[test] + fn mcp_status_shows_connected_and_total_counts() { + assert_eq!( + mcp_status(McpSummary { + connected: 1, + enabled: 2, + has_error: false, + }), + Some(("◐", " 1/2 MCP".to_string())) + ); + assert_eq!( + mcp_status(McpSummary { + connected: 2, + enabled: 2, + has_error: false, + }), + Some(("●", " 2 MCP".to_string())) + ); + } +} diff --git a/src/views/mod.rs b/src/views/mod.rs index badc7ee4..c606a1be 100644 --- a/src/views/mod.rs +++ b/src/views/mod.rs @@ -14,12 +14,14 @@ pub mod remote_dialog; pub mod session_rename_dialog; pub mod sessions_dialog; pub mod skills_dialog; +pub mod status_dialog; pub mod storage_dialog; pub mod suggestions_popup; pub mod terminal_session_dialog; pub mod themes_dialog; pub mod timeline_dialog; pub mod title_dialog; +pub mod variants_dialog; pub mod which_key; pub use agents_dialog::AgentsDialogState; @@ -37,10 +39,12 @@ pub use remote_dialog::RemoteDialogState; pub use session_rename_dialog::SessionRenameDialogState; pub use sessions_dialog::SessionsDialogState; pub use skills_dialog::SkillsDialogState; +pub use status_dialog::StatusDialogState; pub use storage_dialog::StorageDialogState; pub use suggestions_popup::SuggestionsPopupState; pub use terminal_session_dialog::TerminalSessionDialogState; pub use themes_dialog::ThemesDialogState; pub use title_dialog::TitleDialogState; +pub use variants_dialog::VariantsDialogState; #[allow(unused_imports)] pub use which_key::WhichKeyAction; diff --git a/src/views/status_dialog.rs b/src/views/status_dialog.rs new file mode 100644 index 00000000..9e65d274 --- /dev/null +++ b/src/views/status_dialog.rs @@ -0,0 +1,174 @@ +use crate::mcp::McpServerView; +use crate::theme::ThemeColors; +use ratatui::layout::{Alignment, Constraint, Direction, Flex, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span, Text}; +use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph}; +use ratatui::Frame; + +#[derive(Debug, Default)] +pub struct StatusDialogState { + visible: bool, + servers: Vec, + area: Option, +} + +impl StatusDialogState { + pub fn show(&mut self, servers: Vec) { + self.servers = servers; + self.visible = true; + } + + pub fn hide(&mut self) { + self.visible = false; + self.area = None; + } + + pub fn contains(&self, position: ratatui::layout::Position) -> bool { + self.area.is_some_and(|area| area.contains(position)) + } + + pub fn is_visible(&self) -> bool { + self.visible + } +} + +pub fn render_status_dialog( + frame: &mut Frame<'_>, + state: &mut StatusDialogState, + area: Rect, + colors: &ThemeColors, +) { + if !state.visible { + return; + } + + let desired_height = (state.servers.len() as u16 + 5).min(area.height.saturating_sub(2)); + let desired_width = area.width.saturating_sub(4).min(90); + let [dialog_area] = Layout::horizontal([Constraint::Length(desired_width)]) + .flex(Flex::Center) + .areas(area); + let [dialog_area] = Layout::vertical([Constraint::Length(desired_height.max(7))]) + .flex(Flex::Center) + .areas(dialog_area); + state.area = Some(dialog_area); + + frame.render_widget(Clear, dialog_area); + + let block = Block::default() + .borders(Borders::NONE) + .padding(Padding::new(2, 2, 1, 1)) + .style(Style::default().bg(colors.dialog_background)); + let content_area = block.inner(dialog_area); + frame.render_widget(block, dialog_area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(0), + ]) + .split(content_area); + let header_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(0), Constraint::Length(4)]) + .split(chunks[0]); + + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + "Status", + Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD), + ))) + .alignment(Alignment::Left), + header_chunks[0], + ); + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + "esc", + Style::default() + .fg(colors.primary) + .add_modifier(Modifier::BOLD), + ))) + .alignment(Alignment::Right), + header_chunks[1], + ); + + let mut lines = vec![Line::from(Span::styled( + format!("{} MCP Servers", state.servers.len()), + Style::default().fg(colors.text), + ))]; + + if state.servers.is_empty() { + lines.push(Line::from(Span::styled( + " No MCP servers configured", + Style::default().fg(colors.text_weak), + ))); + } else { + for server in &state.servers { + let (bullet_color, status) = match server.status.as_str() { + "connected" => (colors.success, "Connected".to_string()), + "failed" => ( + colors.error, + server + .detail + .clone() + .unwrap_or_else(|| "Connection failed".to_string()), + ), + "needs_auth" => ( + colors.warning, + server + .detail + .clone() + .unwrap_or_else(|| "Authentication required".to_string()), + ), + "connecting" => (colors.warning, "Connecting".to_string()), + "disabled" => (colors.text_weak, "Disabled".to_string()), + status => (colors.text_weak, status.to_string()), + }; + lines.push(Line::from(vec![ + Span::styled("• ", Style::default().fg(bullet_color)), + Span::styled( + server.name.clone(), + Style::default() + .fg(colors.text_strong) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled(status, Style::default().fg(colors.text_weak)), + ])); + } + } + + frame.render_widget(Paragraph::new(Text::from(lines)), chunks[2]); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::theme::Theme; + use ratatui::{backend::TestBackend, Terminal}; + + #[test] + fn rendered_dialog_tracks_bounds_for_outside_click_dismissal() { + let mut state = StatusDialogState::default(); + state.show(Vec::new()); + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).expect("terminal"); + let colors = Theme::load_builtin_default().get_colors(true); + + terminal + .draw(|frame| { + let area = frame.area(); + render_status_dialog(frame, &mut state, area, &colors); + }) + .expect("render status dialog"); + + assert!(state.contains(ratatui::layout::Position::new(40, 12))); + assert!(!state.contains(ratatui::layout::Position::new(0, 0))); + state.hide(); + assert!(!state.contains(ratatui::layout::Position::new(40, 12))); + } +} diff --git a/src/views/variants_dialog.rs b/src/views/variants_dialog.rs new file mode 100644 index 00000000..38fa2e85 --- /dev/null +++ b/src/views/variants_dialog.rs @@ -0,0 +1,246 @@ +use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; +use ratatui::{layout::Rect, Frame}; + +use crate::{ + model::reasoning::{ReasoningCapability, ReasoningEffort}, + theme::ThemeColors, + ui::components::dialog::{Dialog, DialogAction, DialogItem}, +}; + +const DEFAULT_VARIANT_ID: &str = "default"; +const VARIANTS_DIALOG_HEIGHT: u16 = 15; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VariantsDialogAction { + Select, + None, +} + +pub struct VariantsDialogState { + pub dialog: Dialog, +} + +impl VariantsDialogState { + pub fn new() -> Self { + Self { + dialog: Dialog::new("Select variant") + .with_max_height(VARIANTS_DIALOG_HEIGHT) + .with_search_visible(false) + .with_actions(base_actions()), + } + } + + pub fn show(&mut self, capability: &ReasoningCapability, selected: Option) { + let mut items = vec![variant_item( + DEFAULT_VARIANT_ID, + "Default", + selected.is_none(), + )]; + items.extend(capability.values().iter().copied().map(|effort| { + variant_item(effort.as_str(), effort.as_str(), selected == Some(effort)) + })); + self.dialog.set_items(items); + self.dialog.show(); + + let selected_id = selected + .map(ReasoningEffort::as_str) + .unwrap_or(DEFAULT_VARIANT_ID); + self.dialog.select_item_by_id(selected_id); + } + + pub fn selected_effort(&self) -> Option> { + let selected = self.dialog.get_selected()?; + if selected.id == DEFAULT_VARIANT_ID { + Some(None) + } else { + selected.id.parse().ok().map(Some) + } + } +} + +impl Default for VariantsDialogState { + fn default() -> Self { + Self::new() + } +} + +fn base_actions() -> Vec { + vec![ + DialogAction { + label: "Select".to_string(), + key: "enter".to_string(), + }, + DialogAction { + label: "Close".to_string(), + key: "esc".to_string(), + }, + ] +} + +fn variant_item(id: &str, name: &str, active: bool) -> DialogItem { + DialogItem { + id: id.to_string(), + name: name.to_string(), + group: String::new(), + description: String::new(), + tip: None, + provider_id: String::new(), + active, + } +} + +pub fn render_variants_dialog( + frame: &mut Frame, + state: &mut VariantsDialogState, + area: Rect, + colors: ThemeColors, +) { + state.dialog.render(frame, area, colors); +} + +pub fn handle_variants_dialog_key_event( + state: &mut VariantsDialogState, + event: KeyEvent, +) -> VariantsDialogAction { + if !state.dialog.is_visible() { + return VariantsDialogAction::None; + } + + match event.code { + KeyCode::Enter => { + state.dialog.hide(); + VariantsDialogAction::Select + } + _ => { + state.dialog.handle_key_event(event); + VariantsDialogAction::None + } + } +} + +pub fn handle_variants_dialog_mouse_event( + state: &mut VariantsDialogState, + event: MouseEvent, +) -> VariantsDialogAction { + if !state.dialog.is_visible() { + return VariantsDialogAction::None; + } + + let clicked_item = if matches!(event.kind, MouseEventKind::Down(MouseButton::Left)) { + state.dialog.item_index_at_position(event.column, event.row) + } else { + None + }; + + state.dialog.handle_mouse_event(event); + + if clicked_item.is_some() && state.dialog.is_visible() { + state.dialog.hide(); + return VariantsDialogAction::Select; + } + + VariantsDialogAction::None +} + +#[cfg(test)] +mod tests { + use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + use super::*; + + fn shown_state() -> VariantsDialogState { + let capability = ReasoningCapability::effort( + vec![ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ], + ReasoningEffort::Medium, + ); + let mut state = VariantsDialogState::new(); + state.show(&capability, Some(ReasoningEffort::Medium)); + state + } + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + #[test] + fn variants_include_default_and_select_override() { + let mut state = shown_state(); + + assert_eq!(state.selected_effort(), Some(Some(ReasoningEffort::Medium))); + assert!(state.dialog.select_item_by_id(DEFAULT_VARIANT_ID)); + assert_eq!(state.selected_effort(), Some(None)); + } + + #[test] + fn arrow_keys_navigate_without_confirming() { + let mut state = shown_state(); + + assert_eq!( + handle_variants_dialog_key_event(&mut state, key(KeyCode::Down)), + VariantsDialogAction::None + ); + assert!(state.dialog.is_visible()); + assert_eq!(state.selected_effort(), Some(Some(ReasoningEffort::High))); + + assert_eq!( + handle_variants_dialog_key_event(&mut state, key(KeyCode::Up)), + VariantsDialogAction::None + ); + assert_eq!( + handle_variants_dialog_key_event(&mut state, key(KeyCode::Up)), + VariantsDialogAction::None + ); + assert!(state.dialog.is_visible()); + assert_eq!(state.selected_effort(), Some(Some(ReasoningEffort::Low))); + } + + #[test] + fn enter_confirms_highlighted_variant() { + let mut state = shown_state(); + + handle_variants_dialog_key_event(&mut state, key(KeyCode::Down)); + assert_eq!( + handle_variants_dialog_key_event(&mut state, key(KeyCode::Enter)), + VariantsDialogAction::Select + ); + assert!(!state.dialog.is_visible()); + assert_eq!(state.selected_effort(), Some(Some(ReasoningEffort::High))); + } + + #[test] + fn escape_closes_without_confirming() { + let mut state = shown_state(); + + assert_eq!( + handle_variants_dialog_key_event(&mut state, key(KeyCode::Esc)), + VariantsDialogAction::None + ); + assert!(!state.dialog.is_visible()); + assert_eq!(state.selected_effort(), Some(Some(ReasoningEffort::Medium))); + } + + #[test] + fn variants_dialog_is_shorter_than_default_picker() { + use crate::theme::Theme; + use ratatui::{backend::TestBackend, Terminal}; + + let mut state = shown_state(); + let backend = TestBackend::new(80, 40); + let mut terminal = Terminal::new(backend).expect("terminal"); + let colors = Theme::load_builtin_default().get_colors(true); + + terminal + .draw(|frame| { + render_variants_dialog(frame, &mut state, frame.area(), colors); + }) + .expect("render variants dialog"); + + assert_eq!(state.dialog.dialog_area.height, VARIANTS_DIALOG_HEIGHT); + assert!(state.dialog.dialog_area.height < 25); + assert!(state.dialog.visible_row_count >= 5); + } +}