use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use crate::metrics::{McpServerConnectionStatus, MetricsSnapshot, SkillConfidence};
use crate::theme::Theme;
use crate::widgets::panel;
pub(crate) fn sections(
metrics: &MetricsSnapshot,
theme: &Theme,
) -> (Vec<Line<'static>>, Vec<Line<'static>>) {
let confidence_map: std::collections::HashMap<&str, &SkillConfidence> = metrics
.skill_confidence
.iter()
.map(|c| (c.name.as_str(), c))
.collect();
let skill_lines: Vec<Line<'static>> = metrics
.active_skills
.iter()
.map(|s| {
if let Some(conf) = confidence_map.get(s.as_str()) {
let bar = confidence_bar(conf.posterior, 8);
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let pct = (conf.posterior * 100.0) as u32;
let color = confidence_color(conf.posterior);
Line::from(vec![
Span::raw(format!(" {s} ")),
Span::styled(bar, Style::default().fg(color)),
Span::raw(format!(" {pct}% ({})", conf.total_uses)),
])
} else {
Line::from(format!(" - {s}"))
}
})
.collect();
let skills_header = Line::from(Span::styled(
format!(
"skills {} active / {} loaded",
metrics.active_skills.len(),
metrics.total_skills
),
theme.system_message.add_modifier(Modifier::BOLD),
));
let mut skills_content = vec![skills_header];
skills_content.extend(skill_lines);
let has_mcp = !metrics.active_mcp_tools.is_empty() || metrics.mcp_tool_count > 0;
let mut mcp_lines: Vec<Line<'static>> = Vec::new();
if has_mcp {
mcp_lines.push(Line::from(Span::styled(
format!(
"mcp tools {}/{}",
metrics.active_mcp_tools.len(),
metrics.mcp_tool_count
),
theme.system_message.add_modifier(Modifier::BOLD),
)));
for srv in &metrics.mcp_servers {
let (indicator, color) = match srv.status {
McpServerConnectionStatus::Connected => ("ok", Color::Green),
McpServerConnectionStatus::Failed => ("fail", Color::Red),
_ => ("?", Color::DarkGray),
};
let mut spans = vec![
Span::raw(format!(" {} ", srv.id)),
Span::styled(indicator, Style::default().fg(color)),
Span::raw(format!(" ({})", srv.tool_count)),
];
let schemas_dropped = srv.input_schemas_dropped + srv.output_schemas_dropped;
if schemas_dropped > 0 {
spans.push(Span::styled(
format!(" schema-drop:{schemas_dropped}"),
Style::default().fg(Color::Yellow),
));
}
mcp_lines.push(Line::from(spans));
}
for t in &metrics.active_mcp_tools {
mcp_lines.push(Line::from(format!(" - {t}")));
}
}
(skills_content, mcp_lines)
}
#[must_use]
pub fn desired_height(metrics: &MetricsSnapshot, theme: &Theme) -> u16 {
let (skills, mcp) = sections(metrics, theme);
u16::try_from(skills.len() + mcp.len()).unwrap_or(u16::MAX)
}
pub fn render(metrics: &MetricsSnapshot, frame: &mut Frame, area: Rect, theme: &Theme) {
let (skills_content, mcp_lines) = sections(metrics, theme);
if mcp_lines.is_empty() {
panel::render_lines(frame, area, skills_content, theme);
return;
}
let skills_demand = u16::try_from(skills_content.len()).unwrap_or(u16::MAX);
let mcp_demand = u16::try_from(mcp_lines.len()).unwrap_or(u16::MAX);
let skills_h = split_two_sections(skills_demand, mcp_demand, area.height);
let chunks = Layout::vertical([Constraint::Length(skills_h), Constraint::Min(0)]).split(area);
panel::render_lines(frame, chunks[0], skills_content, theme);
panel::render_lines(frame, chunks[1], mcp_lines, theme);
}
fn split_two_sections(first_demand: u16, second_demand: u16, available: u16) -> u16 {
if available == 0 {
return 0;
}
if available == 1 {
return u16::from(first_demand >= 1 || second_demand == 0);
}
let first_floor = u16::from(first_demand >= 1);
let second_floor = u16::from(second_demand >= 1);
let remaining = available - first_floor - second_floor;
let first_room = first_demand.saturating_sub(first_floor);
first_floor + remaining.min(first_room)
}
fn confidence_bar(posterior: f64, width: usize) -> String {
#[allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss
)]
let filled = ((posterior * width as f64).round() as usize).min(width);
let empty = width - filled;
format!("[{}{}]", "█".repeat(filled), "░".repeat(empty))
}
fn confidence_color(posterior: f64) -> Color {
if posterior > 0.75 {
Color::Green
} else if posterior >= 0.40 {
Color::Yellow
} else {
Color::Red
}
}
#[cfg(test)]
mod tests {
use insta::assert_snapshot;
use crate::metrics::{MetricsSnapshot, SkillConfidence};
use crate::test_utils::render_to_string;
#[test]
fn skills_with_data() {
let metrics = MetricsSnapshot {
active_skills: vec!["web-search".into(), "code-gen".into()],
total_skills: 5,
..MetricsSnapshot::default()
};
let theme = crate::theme::Theme::default();
let output = render_to_string(30, 10, |frame, area| {
super::render(&metrics, frame, area, &theme);
});
assert_snapshot!(output);
}
#[test]
fn skills_with_confidence() {
let metrics = MetricsSnapshot {
active_skills: vec!["git".into(), "docker".into()],
total_skills: 3,
skill_confidence: vec![
SkillConfidence {
name: "git".into(),
posterior: 0.92,
total_uses: 42,
},
SkillConfidence {
name: "docker".into(),
posterior: 0.35,
total_uses: 5,
},
],
..MetricsSnapshot::default()
};
let theme = crate::theme::Theme::default();
let output = render_to_string(50, 10, |frame, area| {
super::render(&metrics, frame, area, &theme);
});
assert_snapshot!(output);
}
#[test]
fn confidence_bar_full() {
assert_eq!(super::confidence_bar(1.0, 8), "[████████]");
}
#[test]
fn confidence_bar_empty() {
assert_eq!(super::confidence_bar(0.0, 8), "[░░░░░░░░]");
}
#[test]
fn confidence_bar_half() {
assert_eq!(super::confidence_bar(0.5, 8), "[████░░░░]");
}
#[test]
fn confidence_color_green() {
assert_eq!(super::confidence_color(0.9), ratatui::style::Color::Green);
}
#[test]
fn confidence_color_yellow() {
assert_eq!(super::confidence_color(0.6), ratatui::style::Color::Yellow);
}
#[test]
fn confidence_color_red() {
assert_eq!(super::confidence_color(0.2), ratatui::style::Color::Red);
}
#[test]
fn confidence_color_boundary_exactly_0_75_is_yellow() {
assert_eq!(super::confidence_color(0.75), ratatui::style::Color::Yellow);
}
#[test]
fn confidence_color_boundary_exactly_0_40_is_yellow() {
assert_eq!(super::confidence_color(0.40), ratatui::style::Color::Yellow);
}
#[test]
fn confidence_color_just_below_0_40_is_red() {
assert_eq!(super::confidence_color(0.39), ratatui::style::Color::Red);
}
#[test]
fn confidence_color_just_above_0_75_is_green() {
assert_eq!(super::confidence_color(0.76), ratatui::style::Color::Green);
}
#[test]
fn confidence_bar_width_zero_no_panic() {
let result = super::confidence_bar(0.5, 0);
assert_eq!(result, "[]");
}
#[test]
fn skills_no_confidence_uses_dash_prefix() {
let metrics = MetricsSnapshot {
active_skills: vec!["unknown-skill".into()],
total_skills: 1,
..MetricsSnapshot::default()
};
let theme = crate::theme::Theme::default();
let output = render_to_string(40, 8, |frame, area| {
super::render(&metrics, frame, area, &theme);
});
assert!(
output.contains("- unknown-skill"),
"expected dash prefix, got:\n{output}"
);
}
#[test]
fn split_two_sections_generous_space_gives_first_its_full_demand() {
assert_eq!(super::split_two_sections(5, 3, 20), 5);
}
#[test]
fn split_two_sections_zero_available_gives_nothing() {
assert_eq!(super::split_two_sections(5, 3, 0), 0);
}
#[test]
fn split_two_sections_single_row_favors_first_when_both_want_it() {
assert_eq!(super::split_two_sections(5, 3, 1), 1);
}
#[test]
fn split_two_sections_single_row_goes_to_second_when_first_is_empty() {
assert_eq!(super::split_two_sections(0, 3, 1), 0);
}
#[test]
fn split_two_sections_second_keeps_its_floor_under_pressure() {
let first_h = super::split_two_sections(10, 3, 4);
assert!(
first_h <= 3,
"second section must keep at least 1 row, got first={first_h}"
);
}
#[test]
fn mcp_section_shows_overflow_indicator_instead_of_vanishing_under_pressure() {
let metrics = MetricsSnapshot {
active_skills: vec![
"one".into(),
"two".into(),
"three".into(),
"four".into(),
"five".into(),
],
total_skills: 5,
mcp_tool_count: 3,
active_mcp_tools: vec!["tool-a".into(), "tool-b".into(), "tool-c".into()],
..MetricsSnapshot::default()
};
let theme = crate::theme::Theme::default();
let output = render_to_string(40, 4, |frame, area| {
super::render(&metrics, frame, area, &theme);
});
assert!(
output.contains("more"),
"MCP section must not vanish silently under pressure, got:\n{output}"
);
}
#[test]
fn desired_height_matches_actual_rendered_row_count() {
use crate::metrics::McpServerConnectionStatus;
use crate::test_utils::count_non_blank_rows;
use zeph_core::metrics::McpServerStatus;
let metrics = MetricsSnapshot {
active_skills: vec!["web-search".into(), "code-gen".into()],
total_skills: 5,
skill_confidence: vec![SkillConfidence {
name: "web-search".into(),
posterior: 0.8,
total_uses: 12,
}],
mcp_tool_count: 2,
active_mcp_tools: vec!["tool-a".into()],
mcp_servers: vec![McpServerStatus {
id: "srv1".into(),
status: McpServerConnectionStatus::Connected,
tool_count: 2,
error: String::new(),
input_schemas_dropped: 0,
output_schemas_dropped: 0,
}],
..MetricsSnapshot::default()
};
let theme = crate::theme::Theme::default();
let expected = super::desired_height(&metrics, &theme);
let output = render_to_string(80, 30, |frame, area| {
super::render(&metrics, frame, area, &theme);
});
assert_eq!(
u16::try_from(count_non_blank_rows(&output)).unwrap(),
expected,
"desired_height must match the actual non-blank rendered row count, got:\n{output}"
);
}
}