use ratatui::{
layout::{Constraint, Layout, Rect},
style::{Color, Modifier, Style, Stylize},
text::{self, Line, Span, Text},
widgets::{Block, Borders, Paragraph, Wrap},
Frame,
};
use chrono::Datelike;
use crate::app::{App, FocusSection};
use crate::data::models::ViewData;
use crate::i18n;
use crate::llm::LlmReportType;
use crate::ui::widgets;
use crate::utils::format_tokens;
mod markdown;
use markdown::render_markdown;
fn format_duration(secs: u64) -> String {
if secs == 0 { return "0s".into(); }
if secs < 60 { return format!("{}s", secs); }
if secs < 3600 { return format!("{}m {}s", secs / 60, secs % 60); }
format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
}
pub fn render_summary_view(frame: &mut Frame, content_area: Rect, app: &App) {
let t = i18n::t();
let chunks = Layout::vertical([
Constraint::Length(6),
Constraint::Ratio(1, 2),
Constraint::Ratio(1, 4),
Constraint::Ratio(1, 4),
])
.split(content_area);
let (total_tokens, model_tokens, model_detail, project_items, total_tools) = if let Some(ref data) = app.views.overview_view_data {
let total: u64 = data.total_tokens;
let mt = data.model_tokens.clone();
let md = data.model_token_detail.clone();
let tt = data.total_web_search_requests + data.total_web_fetch_requests + data.total_tool_calls;
let mut items: Vec<(String, u64)> = data
.project_tokens
.iter()
.map(|(k, v)| (k.clone(), *v))
.collect();
items.sort_by(|a, b| b.1.cmp(&a.1));
(total, mt, md, items, tt)
} else {
let total = app.stats.model_usage.values().map(|m| m.total_tokens()).sum();
let mt = app
.stats
.model_usage
.iter()
.map(|(n, u)| (n.clone(), u.total_tokens()))
.collect();
let md = std::collections::HashMap::new();
let tt = 0u64;
let mut items: Vec<(String, u64)> = app
.project_dirs
.iter()
.map(|p| (p.display_name.clone(), 0u64))
.collect();
for summary in &app.views.cached_sessions {
if let Some(idx) = items.iter().position(|n| n.0 == summary.project_name) {
items[idx].1 += summary.total_tokens;
}
}
items.sort_by(|a, b| b.1.cmp(&a.1));
(total, mt, md, items, tt)
};
let project_count = app.project_dirs.len();
let sessions: u64 = app.views.overview_view_data.as_ref().map(|d| d.total_sessions).unwrap_or(app.stats.total_sessions);
let messages: u64 = app.views.overview_view_data.as_ref().map(|d| d.total_messages).unwrap_or(app.stats.total_messages);
let overall_cache_rate = cache_rate_str(&model_detail);
let (longest_str, avg_str, peak) = if let Some(ref d) = app.views.overview_view_data {
(format_duration(d.longest_session_secs), format_duration(d.avg_session_secs), d.peak_period.clone())
} else { ("N/A".into(), "N/A".into(), "N/A".into()) };
let p_str = project_count.to_string();
let s_str = sessions.to_string();
let m_str = messages.to_string();
let tok_str = format_tokens(total_tokens);
let tools_str = total_tools.to_string();
let cards: Vec<(&str, &str, &str)> = vec![
(t.card_projects, &p_str, ""),
(t.card_sessions, &s_str, ""),
(t.card_messages, &m_str, ""),
(t.card_total_tokens, &tok_str, ""),
(t.card_cache_hit_rate, &overall_cache_rate, ""),
(t.card_tool_calls, &tools_str, ""),
];
let cards2: Vec<(&str, &str, &str)> = vec![
(t.card_longest_session, &longest_str, ""),
(t.card_avg_session, &avg_str, ""),
(t.card_peak_period, &peak, ""),
];
let card_rows = Layout::vertical([Constraint::Length(3), Constraint::Length(3)])
.split(chunks[0]);
widgets::render_summary_row(frame, card_rows[0], &cards);
widgets::render_summary_row(frame, card_rows[1], &cards2);
let chart_split = Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)])
.split(chunks[1]);
widgets::render_model_stacked_bars(
frame, chart_split[0], t.chart_model_usage,
&model_tokens, &model_detail,
app.ui.model_scroll, app.ui.focus_section == FocusSection::ModelBars,
);
widgets::render_language_bars(
frame, chart_split[1], t.chart_language_dist, &app.language_distribution,
app.ui.lang_scroll, app.ui.focus_section == FocusSection::LanguageBars,
);
let tool_split = Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)])
.split(chunks[2]);
widgets::render_tool_usage_table(
frame, tool_split[0], t.chart_tool_calls_model,
&model_detail,
app.ui.tool_scroll, app.ui.focus_section == FocusSection::ToolCalls,
);
if let Some(tc) = app.views.overview_view_data.as_ref().map(|d| &d.tool_calls) {
widgets::render_mcp_stats(
frame, tool_split[1], t.chart_mcp_usage,
tc, app.ui.tool_scroll, app.ui.focus_section == FocusSection::ToolCalls,
);
}
let total: u64 = project_items.iter().map(|(_, v)| *v).sum();
let headers = &[t.th_project, t.th_tokens, t.th_percent];
let rows: Vec<Vec<String>> = project_items
.iter()
.map(|(name, tokens)| {
let pct = if total > 0 {
format!("{:5.1}%", (*tokens as f64 / total as f64) * 100.0)
} else {
" 0.0%".to_string()
};
let display = if name.is_empty() { t.project_unknown.to_string() } else { name.clone() };
vec![display, format_tokens(*tokens), pct]
})
.collect();
widgets::render_scrollable_table(
frame, chunks[3], t.chart_project_breakdown,
headers, &rows,
app.ui.project_scroll, app.ui.focus_section == FocusSection::ProjectTable,
);
}
pub fn render_monthly_view(frame: &mut Frame, content_area: Rect, app: &App) {
if let Some(ref data) = app.views.current_view_data {
let t = i18n::t();
let y = app.selected_month.year();
let m = app.selected_month.month();
render_view_data(
frame, content_area, app, data,
t.chart_model_usage_month,
t.chart_project_month,
true, y, m,
);
} else {
render_loading(frame, content_area, app);
}
}
pub fn render_yearly_view(frame: &mut Frame, content_area: Rect, app: &App) {
if let Some(ref data) = app.views.current_view_data {
let t = i18n::t();
let y = app.selected_year;
render_view_data(
frame, content_area, app, data,
t.chart_model_usage_year,
t.chart_project_year,
false, y, 0,
);
} else {
render_loading(frame, content_area, app);
}
}
pub fn render_llm_summary_view(frame: &mut Frame, content_area: Rect, app: &App) {
let chunks = Layout::vertical([
Constraint::Length(3),
Constraint::Min(3),
])
.split(content_area);
render_llm_buttons(frame, chunks[0], app);
render_llm_content(frame, chunks[1], app);
}
fn render_llm_buttons(frame: &mut Frame, area: Rect, app: &App) {
let t = i18n::t();
let block = Block::default()
.borders(Borders::NONE);
frame.render_widget(block, area);
let btn_w = 20u16;
let gap = 2u16;
let total_w = btn_w * 4 + gap * 3;
let start_x = area.x + (area.width.saturating_sub(total_w)) / 2;
let y = area.y;
let thinking_label = if app.llm.thinking_mode { t.llm_thinking_on } else { t.llm_thinking_off };
let buttons: Vec<(LlmReportType, &str, bool)> = vec![
(LlmReportType::All, t.llm_btn_all, false),
(LlmReportType::Monthly, t.llm_btn_monthly, false),
(LlmReportType::Yearly, t.llm_btn_yearly, false),
];
for (i, (t_type, label, _is_thinking)) in buttons.iter().enumerate() {
let x = start_x + (i as u16) * (btn_w + gap);
if x + btn_w > area.x + area.width { break; }
let btn_area = Rect::new(x, y, btn_w, 3);
let is_selected = *t_type == app.llm.report_type;
let style = if is_selected {
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(Color::Reset)
.bg(Color::Reset)
};
let btn_block = Block::default()
.borders(Borders::ALL)
.border_style(style)
.style(style);
let inner = btn_block.inner(btn_area);
frame.render_widget(btn_block, btn_area);
let p = Paragraph::new(Line::from(Span::styled(label.to_string(), style)))
.centered();
frame.render_widget(p, inner);
}
let tx = start_x + 3 * (btn_w + gap);
if tx + btn_w <= area.x + area.width {
let btn_area = Rect::new(tx, y, btn_w, 3);
let t_style = if app.llm.thinking_mode {
Style::default()
.fg(Color::Black)
.bg(Color::Magenta)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(Color::Gray)
.bg(Color::Reset)
};
let btn_block = Block::default()
.borders(Borders::ALL)
.border_style(t_style)
.style(t_style);
let inner = btn_block.inner(btn_area);
frame.render_widget(btn_block, btn_area);
let p = Paragraph::new(Line::from(Span::styled(thinking_label.to_string(), t_style)))
.centered();
frame.render_widget(p, inner);
}
}
fn render_llm_content(frame: &mut Frame, area: Rect, app: &App) {
let t = i18n::t();
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(widgets::COLOR_CARD_BORDER))
.title(t.llm_report_title);
let inner = block.inner(area);
frame.render_widget(block, area);
match &app.llm.data {
Some(text) => {
let mut lines = render_markdown(text);
if let Some(ref thinking) = app.llm.thinking_data {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
t.llm_thinking_header,
Style::default().fg(Color::Magenta).bold(),
)));
lines.push(Line::from(""));
for line in thinking.lines() {
lines.push(Line::from(Span::styled(
line.to_string(),
Style::default().fg(Color::DarkGray).italic(),
)));
}
}
let p = Paragraph::new(Text::from(lines))
.wrap(Wrap { trim: false })
.scroll((app.llm.scroll, 0));
frame.render_widget(p, inner);
}
None => {
let msg = app.llm.status_message.as_deref().unwrap_or(t.llm_empty_hint);
let style = if app.llm.load_state == crate::app::LoadState::Loading {
Style::default().fg(Color::Yellow).bold()
} else {
Style::default().fg(Color::Gray)
};
let p = Paragraph::new(msg).centered().style(style);
frame.render_widget(p, inner);
}
}
}
fn render_view_data(
frame: &mut Frame,
content_area: Rect,
app: &App,
data: &ViewData,
model_title: &str,
project_title: &str,
is_monthly: bool,
year: i32,
month: u32,
) {
let t = i18n::t();
let chunks = Layout::vertical([
Constraint::Length(6),
Constraint::Ratio(1, 2),
Constraint::Ratio(1, 4),
Constraint::Ratio(1, 4),
])
.split(content_area);
let overall_cache_rate = cache_rate_str(&data.model_token_detail);
let total_tools = data.total_web_search_requests + data.total_web_fetch_requests + data.total_tool_calls;
let s_str = data.total_sessions.to_string();
let m_str = data.total_messages.to_string();
let tok_str = format_tokens(data.total_tokens);
let tools_str = total_tools.to_string();
let longest_str = format_duration(data.longest_session_secs);
let avg_str = format_duration(data.avg_session_secs);
let cards: Vec<(&str, &str, &str)> = vec![
(t.card_sessions, &s_str, ""),
(t.card_messages, &m_str, ""),
(t.card_total_tokens, &tok_str, ""),
(t.card_cache_hit_rate, &overall_cache_rate, ""),
(t.card_tool_calls, &tools_str, ""),
];
let cards2: Vec<(&str, &str, &str)> = vec![
(t.card_longest_session, &longest_str, ""),
(t.card_avg_session, &avg_str, ""),
(t.card_peak_period, &data.peak_period.as_str(), ""),
];
let card_rows = Layout::vertical([Constraint::Length(3), Constraint::Length(3)])
.split(chunks[0]);
widgets::render_summary_row(frame, card_rows[0], &cards);
widgets::render_summary_row(frame, card_rows[1], &cards2);
let chart_split = Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)])
.split(chunks[1]);
let activity_data: Vec<(String, u64)> = data
.daily_activity
.iter()
.map(|d| (d.date.clone(), d.message_count.max(d.tool_call_count)))
.collect();
if is_monthly {
widgets::render_monthly_heatmap(frame, chart_split[0], t.chart_daily_activity, &activity_data, year, month);
} else {
widgets::render_yearly_heatmap(frame, chart_split[0], t.chart_daily_activity, &activity_data, year);
}
widgets::render_model_stacked_bars(
frame, chart_split[1], model_title,
&data.model_tokens, &data.model_token_detail,
app.ui.model_scroll, app.ui.focus_section == FocusSection::ModelBars,
);
let tool_split = Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)])
.split(chunks[2]);
widgets::render_tool_usage_table(
frame, tool_split[0], t.chart_tool_calls_model,
&data.model_token_detail,
app.ui.tool_scroll, app.ui.focus_section == FocusSection::ToolCalls,
);
widgets::render_mcp_stats(
frame, tool_split[1], t.chart_mcp_usage,
&data.tool_calls,
app.ui.tool_scroll, app.ui.focus_section == FocusSection::ToolCalls,
);
let mut project_items: Vec<(String, u64)> = data
.project_tokens
.iter()
.map(|(k, v)| (k.clone(), *v))
.collect();
project_items.sort_by(|a, b| b.1.cmp(&a.1));
let total: u64 = project_items.iter().map(|(_, v)| *v).sum();
let headers = &[t.th_project, t.th_tokens, t.th_percent];
let rows: Vec<Vec<String>> = project_items
.iter()
.map(|(name, tokens)| {
let pct = if total > 0 {
format!("{:5.1}%", (*tokens as f64 / total as f64) * 100.0)
} else {
" 0.0%".to_string()
};
let display = if name.is_empty() { t.project_unknown.to_string() } else { name.clone() };
vec![display, format_tokens(*tokens), pct]
})
.collect();
widgets::render_scrollable_table(
frame, chunks[3], project_title,
headers, &rows,
app.ui.project_scroll, app.ui.focus_section == FocusSection::ProjectTable,
);
}
fn cache_rate_str(detail: &std::collections::HashMap<String, crate::data::models::ModelTokenDetail>) -> String {
if detail.is_empty() {
return "N/A".to_string();
}
let total_input: u64 = detail.values().map(|d| d.input_tokens).sum();
let total_cache_read: u64 = detail.values().map(|d| d.cache_read_input_tokens).sum();
let denom = total_input + total_cache_read;
if denom > 0 {
format!("{:.1}%", (total_cache_read as f64 / denom as f64) * 100.0)
} else {
"N/A".to_string()
}
}
fn render_loading(frame: &mut Frame, area: Rect, app: &App) {
let t = i18n::t();
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray));
let inner = block.inner(area);
frame.render_widget(block, area);
let msg = match app.active_tab {
crate::app::Tab::Monthly => {
let month_str = chrono::Month::try_from(app.selected_month.month() as u8)
.map(|mo| format!("{:?}", mo))
.unwrap_or_else(|_| app.selected_month.month().to_string());
format!("{} {} {} ...", t.status_loading, month_str, app.selected_month.year())
}
crate::app::Tab::Yearly => {
format!("{} {} ...", t.status_loading, app.selected_year)
}
crate::app::Tab::LlmSummary => t.loading_generating.to_string(),
_ => t.status_loading.to_string(),
};
let text = text::Text::from(
text::Line::from(
text::Span::styled(msg, Style::default().fg(Color::Yellow).bold()),
),
);
frame.render_widget(Paragraph::new(text).centered(), inner);
}