From ff767738c927c796ce6865e9c5275785f2776149 Mon Sep 17 00:00:00 2001 From: Yanuar Date: Mon, 31 Aug 2026 14:25:28 +0700 Subject: [PATCH 01/10] feat(tui): improve home status parity --- src/app.rs | 60 ++++++++++++++++++++---- src/model/discovery.rs | 7 +++ src/views/home.rs | 101 +++++++++++++++++++++++++++++++++++------ 3 files changed, 144 insertions(+), 24 deletions(-) diff --git a/src/app.rs b/src/app.rs index dbe877ee..67a1cd87 100644 --- a/src/app.rs +++ b/src/app.rs @@ -911,6 +911,8 @@ 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, pub config_raw_merged: serde_json::Value, custom_instructions: String, terminal_focused: bool, @@ -1162,6 +1164,8 @@ 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(), config_raw_merged: serde_json::json!({}), custom_instructions: String::new(), terminal_focused: true, @@ -1225,12 +1229,9 @@ impl App { 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); - }); + 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()); @@ -2721,8 +2722,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 +2761,39 @@ 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 refresh_mcp_summary(&mut self) { + let enabled = self.mcp.values().filter(|server| server.enabled()).count(); + let Some(manager) = self.mcp_manager.as_ref() else { + self.mcp_summary = crate::views::home::McpSummary { + connected: 0, + enabled, + has_error: false, + }; + return; + }; + let Ok(manager) = manager.try_lock() else { + return; + }; + let views = manager.views(); + 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"), + }; + } + fn cycle_reasoning_effort_for_model( &mut self, provider_id: String, @@ -11328,8 +11362,11 @@ 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 usage_text = &self.cached_usage_text; match self.base_focus { BaseFocus::Home => { @@ -11341,11 +11378,12 @@ impl App { status_cwd.clone(), branch.clone(), self.agent.clone(), - self.model.clone(), + model_name, self.provider_name.clone(), reasoning_effort.clone(), + mcp_summary, &colors, - &usage_text, + usage_text, ); if is_suggestions_visible(&self.suggestions_popup_state) @@ -11404,7 +11442,7 @@ impl App { is_compacting, esc_cancel_primed, retry_status.as_ref(), - &usage_text, + usage_text, subagent_tabs, &queued_messages, &mut self.find_bar, @@ -12105,6 +12143,8 @@ 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(), config_raw_merged: serde_json::json!({}), custom_instructions: String::new(), terminal_focused: true, diff --git a/src/model/discovery.rs b/src/model/discovery.rs index d164a4f2..093f27d1 100644 --- a/src/model/discovery.rs +++ b/src/model/discovery.rs @@ -784,6 +784,13 @@ impl Discovery { model.limit.as_ref().map(|l| l.context) } + pub fn get_model_name(&self, provider_id: &str, model_id: &str) -> Option { + 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_model_reasoning_capability( &self, provider_id: &str, 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())) + ); + } +} From 043e5aa59426ad7b1ea5f63b3cc220233b6169c9 Mon Sep 17 00:00:00 2001 From: Yanuar Date: Mon, 31 Aug 2026 14:43:35 +0700 Subject: [PATCH 02/10] fix(tui): use configured model labels --- src/app.rs | 10 +++++++++- src/model/discovery.rs | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/app.rs b/src/app.rs index 67a1cd87..77b9d4d7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2768,6 +2768,13 @@ impl App { .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 enabled = self.mcp.values().filter(|server| server.enabled()).count(); let Some(manager) = self.mcp_manager.as_ref() else { @@ -11366,6 +11373,7 @@ impl App { 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 { @@ -11379,7 +11387,7 @@ impl App { branch.clone(), self.agent.clone(), model_name, - self.provider_name.clone(), + provider_name, reasoning_effort.clone(), mcp_summary, &colors, diff --git a/src/model/discovery.rs b/src/model/discovery.rs index 093f27d1..5723314a 100644 --- a/src/model/discovery.rs +++ b/src/model/discovery.rs @@ -785,12 +785,39 @@ impl Discovery { } 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, @@ -998,6 +1025,14 @@ 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] From 5eeaaf6a05516deebc8139c254fa8218f37dd19b Mon Sep 17 00:00:00 2001 From: Yanuar Date: Mon, 31 Aug 2026 14:51:13 +0700 Subject: [PATCH 03/10] fix(tui): load configured reasoning effort --- src/model/discovery.rs | 64 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/src/model/discovery.rs b/src/model/discovery.rs index 5723314a..193d7969 100644 --- a/src/model/discovery.rs +++ b/src/model/discovery.rs @@ -823,8 +823,15 @@ impl Discovery { 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 @@ -1035,6 +1042,59 @@ mod tests { ); } + #[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] fn custom_model_overlay_preserves_unspecified_catalog_metadata() { let mut providers = HashMap::from([( From 6ce6d652879a3c7aefb713fc3b320c63ca4748af Mon Sep 17 00:00:00 2001 From: Yanuar Date: Mon, 31 Aug 2026 15:28:05 +0700 Subject: [PATCH 04/10] feat(tui): add model variants command --- src/app.rs | 97 ++++++++++++++++++++++++++++++++- src/command/handlers.rs | 41 ++++++++++++++ src/views/mod.rs | 2 + src/views/variants_dialog.rs | 103 +++++++++++++++++++++++++++++++++++ 4 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 src/views/variants_dialog.rs diff --git a/src/app.rs b/src/app.rs index 77b9d4d7..93084e48 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,3 +1,4 @@ +use crate::views::variants_dialog::render_variants_dialog; use ratatui::crossterm::event::{ self, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; @@ -110,7 +111,7 @@ use crate::views::{ ModelsDialogState, MoveSessionDialogState, PermissionDialogState, ProviderOAuthFlowState, QuestionDialogState, RemoteDialogState, SessionRenameDialogState, SessionsDialogState, StorageDialogState, SuggestionsPopupState, TerminalSessionDialogState, ThemesDialogState, - TitleDialogState, + TitleDialogState, VariantsDialogState, }; use crate::{ @@ -225,6 +226,7 @@ pub enum OverlayFocus { None, AgentsDialog, ModelsDialog, + VariantsDialog, RefreshModelsDialog, ThemesDialog, ConnectDialog, @@ -835,6 +837,7 @@ pub struct App { pub suggestions_popup_state: SuggestionsPopupState, pub agents_dialog_state: AgentsDialogState, pub models_dialog_state: ModelsDialogState, + pub variants_dialog_state: VariantsDialogState, pub themes_dialog_state: ThemesDialogState, themes_dialog_original_theme_index: usize, themes_dialog_original_dark_mode: bool, @@ -1023,6 +1026,7 @@ 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 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(); @@ -1095,6 +1099,7 @@ impl App { suggestions_popup_state, agents_dialog_state, models_dialog_state, + variants_dialog_state, themes_dialog_state, themes_dialog_original_theme_index: 0, themes_dialog_original_dark_mode: true, @@ -1415,6 +1420,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); } @@ -4127,6 +4182,15 @@ impl App { } true } + OverlayFocus::VariantsDialog => { + if self.variants_dialog_state.handle_key_event(key) { + self.select_variant(); + } + if !self.variants_dialog_state.dialog.is_visible() { + 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); @@ -4979,6 +5043,13 @@ impl App { if !self.models_dialog_state.dialog.is_visible() { self.overlay_focus = OverlayFocus::None; } + } else if self.overlay_focus == OverlayFocus::VariantsDialog { + if self.variants_dialog_state.dialog.handle_mouse_event(mouse) { + 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); @@ -5577,6 +5648,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 @@ -6519,6 +6603,10 @@ impl App { if self.start_models_command(&mut parsed) { return; } + if parsed.name == "variants" { + self.open_variants_dialog(&parsed.args); + return; + } if parsed.name == "copy" && self.base_focus == BaseFocus::Chat { self.open_copy_actions_dialog(); return; @@ -11498,6 +11586,12 @@ 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::RefreshModelsDialog { crate::views::models_dialog::render_refresh_models_dialog( f, @@ -12082,6 +12176,7 @@ 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(), themes_dialog_state: init_themes_dialog("Themes", vec![], false), themes_dialog_original_theme_index: 0, themes_dialog_original_dark_mode: true, diff --git a/src/command/handlers.rs b/src/command/handlers.rs index 12bde08f..cb222bfe 100644 --- a/src/command/handlers.rs +++ b/src/command/handlers.rs @@ -248,6 +248,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 +951,14 @@ 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![], + chat_only: false, + }); + registry.register(Command { name: "agents".to_string(), description: "Switch agent".to_string(), @@ -1429,6 +1452,24 @@ 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_execute_unknown_command() { let registry = create_registry(); diff --git a/src/views/mod.rs b/src/views/mod.rs index badc7ee4..7fd363f2 100644 --- a/src/views/mod.rs +++ b/src/views/mod.rs @@ -20,6 +20,7 @@ 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; @@ -42,5 +43,6 @@ 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/variants_dialog.rs b/src/views/variants_dialog.rs new file mode 100644 index 00000000..b982800a --- /dev/null +++ b/src/views/variants_dialog.rs @@ -0,0 +1,103 @@ +use ratatui::{crossterm::event::KeyEvent, layout::Rect, Frame}; + +use crate::{ + model::reasoning::{ReasoningCapability, ReasoningEffort}, + theme::ThemeColors, + ui::components::dialog::{Dialog, DialogItem}, +}; + +const DEFAULT_VARIANT_ID: &str = "default"; + +pub struct VariantsDialogState { + pub dialog: Dialog, +} + +impl VariantsDialogState { + pub fn new() -> Self { + Self { + dialog: Dialog::new("Select variant"), + } + } + + 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) + } + } + + pub fn handle_key_event(&mut self, event: KeyEvent) -> bool { + self.dialog.handle_key_event(event) + } +} + +impl Default for VariantsDialogState { + fn default() -> Self { + Self::new() + } +} + +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); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn variants_include_default_and_select_override() { + let capability = ReasoningCapability::effort( + vec![ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ], + ReasoningEffort::Medium, + ); + let mut state = VariantsDialogState::new(); + + state.show(&capability, Some(ReasoningEffort::Medium)); + + 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)); + } +} From 49ba39d99ef63380ce1121d662db4c44f112b954 Mon Sep 17 00:00:00 2001 From: Yanuar Date: Mon, 31 Aug 2026 15:50:42 +0700 Subject: [PATCH 05/10] feat(tui): add status command --- src/app.rs | 40 ++++++++++++- src/command/handlers.rs | 41 +++++++++++++ src/views/mod.rs | 2 + src/views/status_dialog.rs | 115 +++++++++++++++++++++++++++++++++++++ 4 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 src/views/status_dialog.rs diff --git a/src/app.rs b/src/app.rs index 93084e48..b0095aa5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,3 +1,4 @@ +use crate::views::status_dialog::render_status_dialog; use crate::views::variants_dialog::render_variants_dialog; use ratatui::crossterm::event::{ self, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, @@ -110,8 +111,8 @@ use crate::views::{ AgentsDialogState, ChatState, ConnectDialogState, HomeState, JobsDialogState, McpDialogState, ModelsDialogState, MoveSessionDialogState, PermissionDialogState, ProviderOAuthFlowState, QuestionDialogState, RemoteDialogState, SessionRenameDialogState, SessionsDialogState, - StorageDialogState, SuggestionsPopupState, TerminalSessionDialogState, ThemesDialogState, - TitleDialogState, VariantsDialogState, + StatusDialogState, StorageDialogState, SuggestionsPopupState, TerminalSessionDialogState, + ThemesDialogState, TitleDialogState, VariantsDialogState, }; use crate::{ @@ -227,6 +228,7 @@ pub enum OverlayFocus { AgentsDialog, ModelsDialog, VariantsDialog, + StatusDialog, RefreshModelsDialog, ThemesDialog, ConnectDialog, @@ -838,6 +840,7 @@ pub struct App { 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, @@ -916,6 +919,7 @@ pub struct App { 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, @@ -1027,6 +1031,7 @@ impl App { 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(); @@ -1100,6 +1105,7 @@ impl App { 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, @@ -1171,6 +1177,7 @@ impl App { 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, @@ -2854,6 +2861,17 @@ impl App { .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( @@ -4191,6 +4209,13 @@ impl App { } 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); @@ -6607,6 +6632,10 @@ impl App { 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; @@ -11592,6 +11621,11 @@ impl App { 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, &self.status_dialog_state, size, &colors); + } + if self.overlay_focus == OverlayFocus::RefreshModelsDialog { crate::views::models_dialog::render_refresh_models_dialog( f, @@ -12177,6 +12211,7 @@ mod tests { 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, @@ -12248,6 +12283,7 @@ mod tests { 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, diff --git a/src/command/handlers.rs b/src/command/handlers.rs index cb222bfe..231ba3a6 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, @@ -959,6 +974,14 @@ pub fn register_all_commands(registry: &mut Registry) { chat_only: false, }); + registry.register(Command { + name: "status".to_string(), + description: "Show system status".to_string(), + handler: handle_status, + hidden_tokens: vec!["mcp".to_string()], + chat_only: false, + }); + registry.register(Command { name: "agents".to_string(), description: "Switch agent".to_string(), @@ -1470,6 +1493,24 @@ mod tests { )); } + #[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/views/mod.rs b/src/views/mod.rs index 7fd363f2..c606a1be 100644 --- a/src/views/mod.rs +++ b/src/views/mod.rs @@ -14,6 +14,7 @@ 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; @@ -38,6 +39,7 @@ 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; diff --git a/src/views/status_dialog.rs b/src/views/status_dialog.rs new file mode 100644 index 00000000..ba09dcb1 --- /dev/null +++ b/src/views/status_dialog.rs @@ -0,0 +1,115 @@ +use crate::mcp::McpServerView; +use crate::theme::ThemeColors; +use ratatui::layout::{Alignment, Constraint, 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, +} + +impl StatusDialogState { + pub fn show(&mut self, servers: Vec) { + self.servers = servers; + self.visible = true; + } + + pub fn hide(&mut self) { + self.visible = false; + } + + pub fn is_visible(&self) -> bool { + self.visible + } +} + +pub fn render_status_dialog( + frame: &mut Frame<'_>, + state: &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); + + frame.render_widget(Clear, dialog_area); + + let title = Line::from(vec![ + Span::styled( + "Status", + Style::default() + .fg(colors.text_strong) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled("esc", Style::default().fg(colors.text_weak)), + ]); + let block = Block::default() + .borders(Borders::NONE) + .title(title) + .title_alignment(Alignment::Left) + .padding(Padding::new(2, 2, 1, 1)) + .style(Style::default().bg(colors.dialog_background)); + + 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)).block(block), dialog_area); +} From 622832eb87472d74b85559384ce68521ae205cd5 Mon Sep 17 00:00:00 2001 From: Yanuar Date: Mon, 31 Aug 2026 16:41:58 +0700 Subject: [PATCH 06/10] fix(tui): align status dialog header --- src/views/status_dialog.rs | 52 ++++++++++++++++++++++++++++---------- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/src/views/status_dialog.rs b/src/views/status_dialog.rs index ba09dcb1..520e6617 100644 --- a/src/views/status_dialog.rs +++ b/src/views/status_dialog.rs @@ -1,6 +1,6 @@ use crate::mcp::McpServerView; use crate::theme::ThemeColors; -use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect}; +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}; @@ -48,22 +48,46 @@ pub fn render_status_dialog( frame.render_widget(Clear, dialog_area); - let title = Line::from(vec![ - Span::styled( - "Status", - Style::default() - .fg(colors.text_strong) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" "), - Span::styled("esc", Style::default().fg(colors.text_weak)), - ]); let block = Block::default() .borders(Borders::NONE) - .title(title) - .title_alignment(Alignment::Left) .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()), @@ -111,5 +135,5 @@ pub fn render_status_dialog( } } - frame.render_widget(Paragraph::new(Text::from(lines)).block(block), dialog_area); + frame.render_widget(Paragraph::new(Text::from(lines)), chunks[2]); } From 87620bcc00025dc4d34274ef8d5737d282ce6260 Mon Sep 17 00:00:00 2001 From: Yanuar Date: Tue, 1 Sep 2026 10:01:06 +0700 Subject: [PATCH 07/10] fix(tui): address home status review --- src/app.rs | 44 +++++++++++++++++++++++++++++++------- src/command/handlers.rs | 8 ++++--- src/views/status_dialog.rs | 37 +++++++++++++++++++++++++++++++- 3 files changed, 77 insertions(+), 12 deletions(-) diff --git a/src/app.rs b/src/app.rs index b0095aa5..a7be51e4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1240,11 +1240,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_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)); - } + 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() { @@ -2838,11 +2835,10 @@ impl App { } fn refresh_mcp_summary(&mut self) { - let enabled = self.mcp.values().filter(|server| server.enabled()).count(); let Some(manager) = self.mcp_manager.as_ref() else { self.mcp_summary = crate::views::home::McpSummary { connected: 0, - enabled, + enabled: 0, has_error: false, }; return; @@ -2851,6 +2847,7 @@ impl App { return; }; let views = manager.views(); + let enabled = views.iter().filter(|server| server.enabled).count(); self.mcp_summary = crate::views::home::McpSummary { connected: views .iter() @@ -4949,6 +4946,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() @@ -11623,7 +11631,7 @@ impl App { if self.overlay_focus == OverlayFocus::StatusDialog && self.status_dialog_state.is_visible() { - render_status_dialog(f, &self.status_dialog_state, size, &colors); + render_status_dialog(f, &mut self.status_dialog_state, size, &colors); } if self.overlay_focus == OverlayFocus::RefreshModelsDialog { @@ -12633,6 +12641,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 231ba3a6..76672831 100644 --- a/src/command/handlers.rs +++ b/src/command/handlers.rs @@ -976,9 +976,9 @@ pub fn register_all_commands(registry: &mut Registry) { registry.register(Command { name: "status".to_string(), - description: "Show system status".to_string(), + description: "Show MCP status".to_string(), handler: handle_status, - hidden_tokens: vec!["mcp".to_string()], + hidden_tokens: Vec::new(), chat_only: false, }); @@ -1433,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())); @@ -1452,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")); diff --git a/src/views/status_dialog.rs b/src/views/status_dialog.rs index 520e6617..9e65d274 100644 --- a/src/views/status_dialog.rs +++ b/src/views/status_dialog.rs @@ -10,6 +10,7 @@ use ratatui::Frame; pub struct StatusDialogState { visible: bool, servers: Vec, + area: Option, } impl StatusDialogState { @@ -20,6 +21,11 @@ impl StatusDialogState { 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 { @@ -29,7 +35,7 @@ impl StatusDialogState { pub fn render_status_dialog( frame: &mut Frame<'_>, - state: &StatusDialogState, + state: &mut StatusDialogState, area: Rect, colors: &ThemeColors, ) { @@ -45,6 +51,7 @@ pub fn render_status_dialog( 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); @@ -137,3 +144,31 @@ pub fn render_status_dialog( 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))); + } +} From d80186d99bb51eebe35573da9e5a648212a3d0ed Mon Sep 17 00:00:00 2001 From: Blankeos Date: Tue, 1 Sep 2026 18:49:18 +0800 Subject: [PATCH 08/10] fix(tui): confirm variants on enter, not arrow keys Dialog::handle_key_event returns true for Up/Down/Esc/search, so /variants was persisting the adjacent item as soon as you navigated. Match other pickers: arrows move the highlight, Enter (or item click) confirms, Esc/outside-click dismisses without saving. --- src/app.rs | 11 +++- src/views/variants_dialog.rs | 121 ++++++++++++++++++++++++++++++++--- 2 files changed, 121 insertions(+), 11 deletions(-) diff --git a/src/app.rs b/src/app.rs index a7be51e4..7b35670d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,5 +1,8 @@ use crate::views::status_dialog::render_status_dialog; -use crate::views::variants_dialog::render_variants_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, }; @@ -4198,7 +4201,8 @@ impl App { true } OverlayFocus::VariantsDialog => { - if self.variants_dialog_state.handle_key_event(key) { + 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() { @@ -5077,7 +5081,8 @@ impl App { self.overlay_focus = OverlayFocus::None; } } else if self.overlay_focus == OverlayFocus::VariantsDialog { - if self.variants_dialog_state.dialog.handle_mouse_event(mouse) { + 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() { diff --git a/src/views/variants_dialog.rs b/src/views/variants_dialog.rs index b982800a..bf99ca85 100644 --- a/src/views/variants_dialog.rs +++ b/src/views/variants_dialog.rs @@ -1,4 +1,5 @@ -use ratatui::{crossterm::event::KeyEvent, layout::Rect, Frame}; +use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; +use ratatui::{layout::Rect, Frame}; use crate::{ model::reasoning::{ReasoningCapability, ReasoningEffort}, @@ -8,6 +9,12 @@ use crate::{ const DEFAULT_VARIANT_ID: &str = "default"; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VariantsDialogAction { + Select, + None, +} + pub struct VariantsDialogState { pub dialog: Dialog, } @@ -45,10 +52,6 @@ impl VariantsDialogState { selected.id.parse().ok().map(Some) } } - - pub fn handle_key_event(&mut self, event: KeyEvent) -> bool { - self.dialog.handle_key_event(event) - } } impl Default for VariantsDialogState { @@ -78,12 +81,57 @@ pub fn render_variants_dialog( 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::*; - #[test] - fn variants_include_default_and_select_override() { + fn shown_state() -> VariantsDialogState { let capability = ReasoningCapability::effort( vec![ ReasoningEffort::Low, @@ -93,11 +141,68 @@ mod tests { 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))); + } } From 5f57e4616e93be7f3d82c3860027565744051b97 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Tue, 1 Sep 2026 19:33:49 +0800 Subject: [PATCH 09/10] fix(tui): polish variants dialog and ctrl-p status/variant actions Make /variants a compact picker that confirms on Enter, wire variants and status through process_command_input so Ctrl-P actually opens them, and align palette labels/search with OpenCode-style variant wording. --- src/app.rs | 38 ++++++++++++++++++ src/command/handlers.rs | 4 +- src/ui/components/dialog.rs | 15 +++++++- src/views/command_palette.rs | 74 ++++++++++++++++++++++++++++++++++-- src/views/variants_dialog.rs | 40 ++++++++++++++++++- 5 files changed, 162 insertions(+), 9 deletions(-) diff --git a/src/app.rs b/src/app.rs index 7b35670d..eaf7d82d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -6889,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; @@ -12554,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(); diff --git a/src/command/handlers.rs b/src/command/handlers.rs index 76672831..f136b295 100644 --- a/src/command/handlers.rs +++ b/src/command/handlers.rs @@ -970,13 +970,13 @@ pub fn register_all_commands(registry: &mut Registry) { name: "variants".to_string(), description: "Switch model variant".to_string(), handler: handle_variants, - hidden_tokens: vec![], + hidden_tokens: vec!["reasoning effort".to_string()], chat_only: false, }); registry.register(Command { name: "status".to_string(), - description: "Show MCP status".to_string(), + description: "Show status".to_string(), handler: handle_status, hidden_tokens: Vec::new(), chat_only: false, diff --git a/src/ui/components/dialog.rs b/src/ui/components/dialog.rs index 5dcee88b..1f343ebb 100644 --- a/src/ui/components/dialog.rs +++ b/src/ui/components/dialog.rs @@ -90,6 +90,7 @@ pub struct Dialog { pub actions: Vec, bottom_gap_height: u16, pub position: DialogPosition, + max_height: Option, pub pending_delete_id: Option, collapsible_groups: bool, collapsed_groups: HashSet, @@ -128,6 +129,7 @@ impl Dialog { actions: Vec::new(), bottom_gap_height: 1, position: DialogPosition::Center, + max_height: None, pending_delete_id: None, collapsible_groups: false, collapsed_groups: HashSet::new(), @@ -143,6 +145,11 @@ impl Dialog { self } + pub fn with_max_height(mut self, height: u16) -> Self { + self.max_height = Some(height.max(1)); + self + } + pub fn with_collapsible_groups(mut self, enabled: bool) -> Self { self.collapsible_groups = enabled; if !enabled { @@ -975,8 +982,9 @@ impl Dialog { 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 => { @@ -1585,7 +1593,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, @@ -1886,6 +1896,7 @@ impl Clone for Dialog { actions: self.actions.clone(), bottom_gap_height: self.bottom_gap_height, position: self.position, + max_height: self.max_height, 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/variants_dialog.rs b/src/views/variants_dialog.rs index bf99ca85..64c8227e 100644 --- a/src/views/variants_dialog.rs +++ b/src/views/variants_dialog.rs @@ -4,10 +4,11 @@ use ratatui::{layout::Rect, Frame}; use crate::{ model::reasoning::{ReasoningCapability, ReasoningEffort}, theme::ThemeColors, - ui::components::dialog::{Dialog, DialogItem}, + ui::components::dialog::{Dialog, DialogAction, DialogItem}, }; const DEFAULT_VARIANT_ID: &str = "default"; +const VARIANTS_DIALOG_HEIGHT: u16 = 14; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum VariantsDialogAction { @@ -22,7 +23,9 @@ pub struct VariantsDialogState { impl VariantsDialogState { pub fn new() -> Self { Self { - dialog: Dialog::new("Select variant"), + dialog: Dialog::new("Select variant") + .with_max_height(VARIANTS_DIALOG_HEIGHT) + .with_actions(base_actions()), } } @@ -60,6 +63,19 @@ impl Default for VariantsDialogState { } } +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(), @@ -205,4 +221,24 @@ mod tests { 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); + } } From 9b61a729274cecf2e34ecddffcd310490cdce2dc Mon Sep 17 00:00:00 2001 From: Blankeos Date: Tue, 1 Sep 2026 19:40:36 +0800 Subject: [PATCH 10/10] fix(tui): show at least five variant options without scrolling Hide the unused search box on /variants and give the list five visible rows so Default plus low/medium/high/xhigh all fit. --- src/ui/components/dialog.rs | 28 +++++++++++++++++++++++++--- src/views/variants_dialog.rs | 4 +++- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/ui/components/dialog.rs b/src/ui/components/dialog.rs index 1f343ebb..1592b876 100644 --- a/src/ui/components/dialog.rs +++ b/src/ui/components/dialog.rs @@ -91,6 +91,7 @@ pub struct Dialog { bottom_gap_height: u16, pub position: DialogPosition, max_height: Option, + search_visible: bool, pub pending_delete_id: Option, collapsible_groups: bool, collapsed_groups: HashSet, @@ -130,6 +131,7 @@ impl Dialog { bottom_gap_height: 1, position: DialogPosition::Center, max_height: None, + search_visible: true, pending_delete_id: None, collapsible_groups: false, collapsed_groups: HashSet::new(), @@ -150,6 +152,22 @@ impl Dialog { 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 { @@ -976,7 +994,7 @@ 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; @@ -1074,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()), @@ -1263,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); @@ -1669,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 @@ -1897,6 +1918,7 @@ impl Clone for Dialog { 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/variants_dialog.rs b/src/views/variants_dialog.rs index 64c8227e..38fa2e85 100644 --- a/src/views/variants_dialog.rs +++ b/src/views/variants_dialog.rs @@ -8,7 +8,7 @@ use crate::{ }; const DEFAULT_VARIANT_ID: &str = "default"; -const VARIANTS_DIALOG_HEIGHT: u16 = 14; +const VARIANTS_DIALOG_HEIGHT: u16 = 15; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum VariantsDialogAction { @@ -25,6 +25,7 @@ impl VariantsDialogState { Self { dialog: Dialog::new("Select variant") .with_max_height(VARIANTS_DIALOG_HEIGHT) + .with_search_visible(false) .with_actions(base_actions()), } } @@ -240,5 +241,6 @@ mod tests { assert_eq!(state.dialog.dialog_area.height, VARIANTS_DIALOG_HEIGHT); assert!(state.dialog.dialog_area.height < 25); + assert!(state.dialog.visible_row_count >= 5); } }