use std::sync::Arc;
use crossterm::event::{KeyCode, KeyEvent};
use oxi_agent::mcp::{ConsentState, McpConnectionStatus, McpManager, McpServerInfo, McpToolInfo};
use oxi_tui::Theme;
use oxi_tui::widgets::dashboard::{
DashboardData, DashboardItem, DashboardSection, DashboardState, DashboardWidget, ItemStatus,
};
use ratatui::{Frame, layout::Rect, widgets::StatefulWidget};
use super::{OverlayAction, OverlayComponent, centered_layout};
pub struct McpDashboardOverlay {
widget: DashboardWidget,
state: DashboardState,
manager: Arc<McpManager>,
needs_refresh: bool,
}
impl std::fmt::Debug for McpDashboardOverlay {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("McpDashboardOverlay").finish()
}
}
impl McpDashboardOverlay {
pub fn new(manager: Arc<McpManager>) -> Self {
let data = build_dashboard_data(manager.clone());
let widget = DashboardWidget::new(data);
Self {
widget,
state: DashboardState::new(),
manager,
needs_refresh: false,
}
}
#[allow(dead_code)]
pub fn mark_refresh(&mut self) {
self.needs_refresh = true;
}
fn selected_id(&self) -> Option<String> {
let data = self.widget.data();
data.sections
.get(self.state.selected_section)
.and_then(|s| {
s.items
.iter()
.filter(|i| {
self.state.filter.is_empty() || item_label_matches(i, &self.state.filter)
})
.nth(self.state.selected_item)
.map(|i| i.id.clone())
})
}
}
impl OverlayComponent for McpDashboardOverlay {
fn handle_key(&mut self, key: KeyEvent) -> OverlayAction {
if self.state.filter_editing {
match key.code {
KeyCode::Esc => self.state.toggle_filter(),
KeyCode::Enter => self.state.toggle_filter(),
KeyCode::Backspace => self.state.filter_pop(),
KeyCode::Char(c) => self.state.filter_push(c),
_ => {}
}
return OverlayAction::None;
}
match key.code {
KeyCode::Esc | KeyCode::Char('q') => OverlayAction::Close,
KeyCode::Up | KeyCode::Char('k') => {
let data = self.widget.data().clone();
self.state.select_previous(&data);
OverlayAction::None
}
KeyCode::Down | KeyCode::Char('j') => {
let data = self.widget.data().clone();
self.state.select_next(&data);
OverlayAction::None
}
KeyCode::Char('r') => {
if let Some(id) = self.selected_id() {
OverlayAction::McpAction(McpAction::Reconnect(id))
} else {
OverlayAction::None
}
}
KeyCode::Char('R') => {
OverlayAction::McpAction(McpAction::ReconnectAll)
}
KeyCode::Char('D') => {
if let Some(id) = self.selected_id() {
OverlayAction::McpAction(McpAction::Disconnect(id))
} else {
OverlayAction::None
}
}
KeyCode::Char('a') => {
if let Some(id) = self.selected_id() {
OverlayAction::McpAction(McpAction::SetConsent {
name: id,
state: ConsentState::Allow,
})
} else {
OverlayAction::None
}
}
KeyCode::Char('x') => {
if let Some(id) = self.selected_id() {
OverlayAction::McpAction(McpAction::SetConsent {
name: id,
state: ConsentState::Deny,
})
} else {
OverlayAction::None
}
}
KeyCode::Char('/') => {
self.state.toggle_filter();
OverlayAction::None
}
KeyCode::Tab => {
let n = self.widget.data().sections.len();
if n > 0 {
self.state.selected_section = (self.state.selected_section + 1) % n;
self.state.selected_item = 0;
}
OverlayAction::None
}
_ => OverlayAction::None,
}
}
fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
if self.needs_refresh {
let data = build_dashboard_data(self.manager.clone());
self.widget.set_data(data);
self.needs_refresh = false;
}
let popup = centered_layout(area, 0.9, 0.85);
frame.render_widget(ratatui::widgets::Clear, popup);
let data = self.widget.data().clone();
let widget = DashboardWidget::new(data);
let buf = frame.buffer_mut();
widget.render(popup, buf, &mut self.state);
let _ = theme;
}
fn hint(&self) -> &str {
"↑↓/jk Navigate │ r:Reconnect │ R:Reconnect All │ D:Disconnect │ a:Allow │ x:Deny │ Tab:Section │ /:Filter │ Esc:Close"
}
}
fn build_dashboard_data(manager: Arc<McpManager>) -> DashboardData {
let data = manager.dashboard_data();
let mut dashboard = DashboardData::new()
.with_header(vec![format!(
"MCP — {}/{} servers, {} tools",
data.settings.connected_servers, data.settings.total_servers, data.settings.total_tools,
)])
.with_footer(vec![format!(
"Tool prefix: {} Idle timeout: {}m",
data.settings.tool_prefix,
data.settings
.idle_timeout
.map(|m| m.to_string())
.unwrap_or_else(|| "default(10)".to_string()),
)]);
let server_items: Vec<DashboardItem> = data.servers.iter().map(server_to_item).collect();
dashboard = dashboard.add_section(
DashboardSection::new(format!("Servers ({})", data.servers.len())).with_items(server_items),
);
for server in &data.servers {
if server.tools.is_empty() {
continue;
}
let items: Vec<DashboardItem> = server.tools.iter().map(tool_to_item).collect();
dashboard = dashboard.add_section(
DashboardSection::new(format!("Tools: {} ({})", server.name, items.len()))
.with_items(items),
);
}
dashboard
}
fn server_to_item(server: &McpServerInfo) -> DashboardItem {
let status = match &server.status {
McpConnectionStatus::Connected => ItemStatus::Active,
McpConnectionStatus::Disconnected | McpConnectionStatus::Connecting => ItemStatus::Inactive,
McpConnectionStatus::Error(msg) => ItemStatus::Error(msg.clone()),
};
let detail = match &server.status {
McpConnectionStatus::Connected => format!("{} tools", server.tool_count),
McpConnectionStatus::Disconnected => format!("{} tools (cached)", server.tool_count),
McpConnectionStatus::Connecting => "connecting…".to_string(),
McpConnectionStatus::Error(msg) => msg.clone(),
};
DashboardItem {
id: server.name.clone(),
label: server.name.clone(),
detail,
status,
badges: vec![server.lifecycle.clone()],
}
}
fn tool_to_item(tool: &McpToolInfo) -> DashboardItem {
let consent_label = match tool.consent {
ConsentState::Allow => "ALLOW",
ConsentState::Deny => "DENY",
};
let mut badges = vec![];
if tool.is_direct {
badges.push("DIRECT".to_string());
} else {
badges.push("PROXY".to_string());
}
badges.push(consent_label.to_string());
DashboardItem {
id: tool.name.clone(),
label: tool.name.clone(),
detail: if tool.description.is_empty() {
String::new()
} else {
tool.description.clone()
},
status: ItemStatus::Active,
badges,
}
}
fn item_label_matches(item: &DashboardItem, filter: &str) -> bool {
let f = filter.to_lowercase();
item.label.to_lowercase().contains(&f)
|| item.id.to_lowercase().contains(&f)
|| item.detail.to_lowercase().contains(&f)
}
#[derive(Debug, Clone)]
pub enum McpAction {
Reconnect(String),
ReconnectAll,
Disconnect(String),
SetConsent {
name: String,
state: ConsentState,
},
#[allow(dead_code)]
Refresh,
}