use ratatui::{
buffer::Buffer,
layout::Rect,
style::Modifier,
text::{Line, Span},
widgets::{Block, Borders, Paragraph, StatefulWidget, Widget},
};
use crate::Theme;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderHealth {
Healthy,
Degraded,
Unavailable,
Disabled,
}
impl ProviderHealth {
fn symbol(&self) -> &'static str {
match self {
ProviderHealth::Healthy => "\u{25CF}", ProviderHealth::Degraded => "\u{25CF}", ProviderHealth::Unavailable => "\u{25CB}", ProviderHealth::Disabled => "\u{25CB}", }
}
}
#[derive(Debug, Clone)]
pub struct ProviderInfo {
pub name: String,
pub health: ProviderHealth,
pub failures: u32,
pub is_active: bool,
}
#[derive(Debug, Clone)]
pub struct RoutingStatusData {
pub auto_routing_enabled: bool,
pub fallback_enabled: bool,
pub fallback_chain: Vec<ProviderInfo>,
pub active_index: usize,
}
impl Default for RoutingStatusData {
fn default() -> Self {
Self {
auto_routing_enabled: true,
fallback_enabled: true,
fallback_chain: Vec::new(),
active_index: 0,
}
}
}
impl RoutingStatusData {
pub fn active_provider_name(&self) -> Option<&str> {
self.fallback_chain
.get(self.active_index)
.map(|p| p.name.as_str())
}
pub fn chain_abbreviated(&self) -> String {
self.fallback_chain
.iter()
.map(|p| {
let short = if p.name.len() > 4 {
&p.name[..4]
} else {
&p.name
};
if p.is_active {
format!("[{}]", short)
} else {
short.to_string()
}
})
.collect::<Vec<_>>()
.join(" > ")
}
}
#[derive(Debug, Default)]
pub struct RoutingStatusState {
pub visible: bool,
pub data: RoutingStatusData,
}
impl RoutingStatusState {
pub fn new() -> Self {
Self::default()
}
pub fn toggle(&mut self) {
self.visible = !self.visible;
}
pub fn show(&mut self) {
self.visible = true;
}
pub fn hide(&mut self) {
self.visible = false;
}
pub fn set_data(&mut self, data: RoutingStatusData) {
self.data = data;
}
}
pub struct RoutingStatus<'a> {
theme: &'a Theme,
}
impl<'a> RoutingStatus<'a> {
pub fn new(theme: &'a Theme) -> Self {
Self { theme }
}
}
impl StatefulWidget for RoutingStatus<'_> {
type State = RoutingStatusState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
if !state.visible || area.width < 10 || area.height < 4 {
return;
}
let styles = self.theme.to_styles();
let d = &state.data;
let title = " \u{2699} Routing Status ";
let block = Block::default()
.borders(Borders::ALL)
.border_style(styles.border)
.title(Span::styled(
title,
styles.accent.add_modifier(Modifier::BOLD),
));
let inner = block.inner(area);
block.render(area, buf);
let max_w = inner.width as usize;
let mut lines: Vec<Line<'static>> = Vec::new();
{
let enabled_ch = if d.auto_routing_enabled {
"\u{25CF}"
} else {
"\u{25CB}"
};
let enabled_style = if d.auto_routing_enabled {
styles.success
} else {
styles.muted
};
let enabled_label = if d.auto_routing_enabled { "ON" } else { "OFF" };
let fallback_ch = if d.fallback_enabled {
"\u{25CF}"
} else {
"\u{25CB}"
};
let fallback_style = if d.fallback_enabled {
styles.success
} else {
styles.muted
};
let fallback_label = if d.fallback_enabled { "ON" } else { "OFF" };
lines.push(Line::from(vec![
Span::styled(" Auto-Route ", styles.muted),
Span::styled(enabled_ch, enabled_style),
Span::styled(format!(" {} ", enabled_label), enabled_style),
Span::styled(" Fallback ", styles.muted),
Span::styled(fallback_ch, fallback_style),
Span::styled(format!(" {}", fallback_label), fallback_style),
]));
}
lines.push(Line::from(Span::styled(
" \u{2500}".repeat(max_w.saturating_sub(2)),
styles.border,
)));
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("Fallback Chain:", styles.muted),
]));
if d.fallback_chain.is_empty() {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("(none configured)", styles.muted),
]));
} else {
for provider in d.fallback_chain.iter() {
let health_ch = provider.health.symbol();
let health_style = match provider.health {
ProviderHealth::Healthy => styles.success,
ProviderHealth::Degraded => styles.warning,
ProviderHealth::Unavailable => styles.error,
ProviderHealth::Disabled => styles.muted,
};
let active_marker = if provider.is_active {
" \u{25B6}"
} else {
" "
};
let mut line_spans: Vec<Span<'static>> = vec![
Span::raw(" "),
Span::styled(health_ch, health_style),
Span::raw(active_marker),
Span::styled(
provider.name.clone(),
if provider.is_active {
styles.primary
} else {
styles.muted
},
),
];
if provider.failures > 0 {
line_spans.push(Span::styled(
format!(
" ({} fail{})",
provider.failures,
if provider.failures == 1 { "" } else { "s" }
),
styles.warning,
));
}
lines.push(Line::from(line_spans));
}
}
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("Ctrl+R ", styles.primary),
Span::styled("to close", styles.muted),
]));
let text: ratatui::text::Text = lines.into_iter().collect();
Paragraph::new(text).render(inner, buf);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn provider_health_symbols() {
assert_eq!(ProviderHealth::Healthy.symbol(), "\u{25CF}");
assert_eq!(ProviderHealth::Degraded.symbol(), "\u{25CF}");
assert_eq!(ProviderHealth::Unavailable.symbol(), "\u{25CB}");
assert_eq!(ProviderHealth::Disabled.symbol(), "\u{25CB}");
}
#[test]
fn routing_status_data_default() {
let data = RoutingStatusData::default();
assert!(data.auto_routing_enabled);
assert!(data.fallback_enabled);
assert!(data.fallback_chain.is_empty());
assert_eq!(data.active_index, 0);
}
#[test]
fn chain_abbreviated_empty() {
let data = RoutingStatusData::default();
assert_eq!(data.chain_abbreviated(), "");
}
#[test]
fn chain_abbreviated_single() {
let data = RoutingStatusData {
fallback_chain: vec![ProviderInfo {
name: "anthropic".to_string(),
health: ProviderHealth::Healthy,
failures: 0,
is_active: true,
}],
..Default::default()
};
assert_eq!(data.chain_abbreviated(), "[anth]");
}
#[test]
fn chain_abbreviated_multiple() {
let data = RoutingStatusData {
fallback_chain: vec![
ProviderInfo {
name: "anthropic".to_string(),
health: ProviderHealth::Healthy,
failures: 0,
is_active: true,
},
ProviderInfo {
name: "openai".to_string(),
health: ProviderHealth::Degraded,
failures: 3,
is_active: false,
},
],
..Default::default()
};
assert_eq!(data.chain_abbreviated(), "[anth] > open");
}
#[test]
fn routing_status_state_toggle() {
let mut state = RoutingStatusState::new();
assert!(!state.visible);
state.toggle();
assert!(state.visible);
state.toggle();
assert!(!state.visible);
}
}