diff --git a/src/analytics/agent_stats.rs b/src/analytics/agent_stats.rs new file mode 100644 index 0000000..18e1421 --- /dev/null +++ b/src/analytics/agent_stats.rs @@ -0,0 +1,311 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use chrono::NaiveDate; + +use crate::cache::models_cache::PricingCatalog; +use crate::db::models::{TokenUsage, UsageEvent}; +use crate::utils::formatting::percentage; +use crate::utils::pricing::{PriceSummary, ZeroCostBehavior, update_price_summary}; +use crate::utils::time::TimeRange; + +use super::model_stats::UsageAccumulator; + +#[derive(Clone, Debug)] +pub struct AgentModelBreakdown { + pub model_id: String, + pub tokens: u64, + pub cost: PriceSummary, + pub sessions: usize, +} + +#[derive(Clone, Debug)] +pub struct AgentUsageRow { + pub agent_id: String, + pub total_tokens: u64, + pub input_tokens: u64, + pub output_tokens: u64, + pub cache_tokens: u64, + pub sessions: usize, + pub active_days: usize, + pub cost: PriceSummary, + pub percentage: f64, + pub p50_output_tokens_per_second: f64, + pub model_breakdown: Vec, +} + +use super::model_stats::{ModelChartData, build_chart_for_models, median}; + +pub fn build_agent_chart( + events: &[UsageEvent], + pricing: &PricingCatalog, + range: TimeRange, + today: NaiveDate, + zero_cost_behavior: ZeroCostBehavior, +) -> (Vec, ModelChartData) { + let mut agent_rows = BTreeMap::::new(); + let mut agent_model_tokens = BTreeMap::>::new(); + let mut agent_model_cost = BTreeMap::>::new(); + let mut agent_model_sessions = BTreeMap::>>::new(); + + for event in events { + let agent = event + .agent + .clone() + .filter(|a| !a.is_empty()) + .unwrap_or_else(|| "unknown".to_string()); + let model = event.model_id.clone(); + + let entry = agent_rows.entry(agent.clone()).or_default(); + entry.tokens.add_assign(&event.tokens); + entry.sessions.insert(event.session_id.clone()); + update_price_summary(&mut entry.cost, pricing, event, zero_cost_behavior); + if let Some(date) = event.activity_date() { + entry.active_days.insert(date); + let total = entry.daily_tokens.entry(date).or_default(); + *total = total.saturating_add(event.tokens.total()); + } + if event.is_rate_eligible() + && let Some(duration_ms) = event.duration_ms() + { + let rate = event.tokens.output as f64 / (duration_ms as f64 / 1_000.0); + entry.output_rates.push(rate); + } + + let model_tokens = agent_model_tokens + .entry(agent.clone()) + .or_default() + .entry(model.clone()) + .or_default(); + model_tokens.add_assign(&event.tokens); + + let model_cost = agent_model_cost + .entry(agent.clone()) + .or_default() + .entry(model.clone()) + .or_default(); + update_price_summary(model_cost, pricing, event, zero_cost_behavior); + + agent_model_sessions + .entry(agent.clone()) + .or_default() + .entry(model.clone()) + .or_default() + .insert(event.session_id.clone()); + } + + let overall_tokens = agent_rows + .values() + .map(|row| row.tokens.total()) + .fold(0u64, |total, value| total.saturating_add(value)); + let mut rows = agent_rows + .into_iter() + .map(|(agent_id, row)| { + let model_breakdown = agent_model_tokens + .get(&agent_id) + .map(|models| { + let mut breakdown: Vec = models + .iter() + .map(|(model_id, tokens)| AgentModelBreakdown { + model_id: model_id.clone(), + tokens: tokens.total(), + cost: agent_model_cost + .get(&agent_id) + .and_then(|costs| costs.get(model_id).cloned()) + .unwrap_or_default(), + sessions: agent_model_sessions + .get(&agent_id) + .and_then(|sessions| sessions.get(model_id)) + .map(|s| s.len()) + .unwrap_or(0), + }) + .collect(); + breakdown.sort_by_key(|b| std::cmp::Reverse(b.tokens)); + breakdown + }) + .unwrap_or_default(); + + AgentUsageRow { + agent_id, + total_tokens: row.tokens.total(), + input_tokens: row.tokens.input, + output_tokens: row.tokens.output, + cache_tokens: row.tokens.cache_read.saturating_add(row.tokens.cache_write), + percentage: percentage(row.tokens.total(), overall_tokens), + sessions: row.sessions.len(), + active_days: row.active_days.len(), + cost: row.cost, + p50_output_tokens_per_second: median(&row.output_rates), + model_breakdown, + } + }) + .collect::>(); + + rows.sort_by_key(|right| std::cmp::Reverse(right.total_tokens)); + + let top_agents = rows + .iter() + .map(|row| row.agent_id.clone()) + .collect::>(); + let chart = build_chart_for_models(events, &top_agents, range, today, |event| { + event + .agent + .clone() + .filter(|a| !a.is_empty()) + .unwrap_or_else(|| "unknown".to_string()) + }); + (rows, chart) +} + +#[cfg(test)] +mod tests { + use super::build_agent_chart; + use crate::db::models::{DataSourceKind, TokenUsage, UsageEvent}; + use crate::utils::time::TimeRange; + use chrono::{Local, TimeZone}; + + #[test] + fn agents_group_events_by_agent_field() { + let created_at = Local + .with_ymd_and_hms(2026, 3, 12, 9, 30, 0) + .single() + .unwrap(); + let day = created_at.date_naive(); + let events = vec![ + UsageEvent { + session_id: "ses_1".to_string(), + parent_session_id: None, + session_title: None, + session_started_at: Some(created_at), + session_archived_at: None, + project_name: None, + project_path: None, + provider_id: Some("openai".to_string()), + model_id: "gpt-5".to_string(), + agent: Some("build".to_string()), + finish_reason: Some("stop".to_string()), + tokens: TokenUsage { + input: 100, + output: 200, + cache_read: 0, + cache_write: 0, + }, + created_at: Some(created_at), + completed_at: Some(created_at), + stored_cost_usd: None, + source: DataSourceKind::Json, + }, + UsageEvent { + session_id: "ses_2".to_string(), + parent_session_id: Some("ses_1".to_string()), + session_title: None, + session_started_at: Some(created_at), + session_archived_at: None, + project_name: None, + project_path: None, + provider_id: Some("anthropic".to_string()), + model_id: "claude-sonnet".to_string(), + agent: Some("explore".to_string()), + finish_reason: Some("stop".to_string()), + tokens: TokenUsage { + input: 50, + output: 100, + cache_read: 0, + cache_write: 0, + }, + created_at: Some(created_at), + completed_at: Some(created_at), + stored_cost_usd: None, + source: DataSourceKind::Json, + }, + UsageEvent { + session_id: "ses_3".to_string(), + parent_session_id: None, + session_title: None, + session_started_at: Some(created_at), + session_archived_at: None, + project_name: None, + project_path: None, + provider_id: Some("openai".to_string()), + model_id: "gpt-5.5".to_string(), + agent: Some("build".to_string()), + finish_reason: Some("stop".to_string()), + tokens: TokenUsage { + input: 300, + output: 400, + cache_read: 0, + cache_write: 0, + }, + created_at: Some(created_at), + completed_at: Some(created_at), + stored_cost_usd: None, + source: DataSourceKind::Json, + }, + UsageEvent { + session_id: "ses_1".to_string(), + parent_session_id: None, + session_title: None, + session_started_at: Some(created_at), + session_archived_at: None, + project_name: None, + project_path: None, + provider_id: Some("unknown".to_string()), + model_id: "unknown-model".to_string(), + agent: None, + finish_reason: Some("stop".to_string()), + tokens: TokenUsage { + input: 10, + output: 20, + cache_read: 0, + cache_write: 0, + }, + created_at: Some(created_at), + completed_at: Some(created_at), + stored_cost_usd: None, + source: DataSourceKind::Json, + }, + ]; + + let pricing = crate::cache::models_cache::PricingCatalog { + models: std::collections::BTreeMap::new(), + cache_path: std::path::PathBuf::from("/tmp/models.json"), + refresh_needed: false, + availability: crate::cache::models_cache::PricingAvailability::Empty, + load_notice: None, + }; + let (rows, _chart) = build_agent_chart( + &events, + &pricing, + TimeRange::All, + day, + crate::utils::pricing::ZeroCostBehavior::KeepZero, + ); + + assert_eq!(rows.len(), 3); + + assert_eq!(rows[0].agent_id, "build"); + assert_eq!(rows[0].total_tokens, 1000); + assert_eq!(rows[0].sessions, 2); + assert_eq!(rows[0].model_breakdown.len(), 2); + assert_eq!(rows[0].model_breakdown[0].model_id, "gpt-5.5"); + assert_eq!(rows[0].model_breakdown[0].tokens, 700); + assert_eq!(rows[0].model_breakdown[0].sessions, 1); + assert_eq!(rows[0].model_breakdown[1].model_id, "gpt-5"); + assert_eq!(rows[0].model_breakdown[1].tokens, 300); + assert_eq!(rows[0].model_breakdown[1].sessions, 1); + + assert_eq!(rows[1].agent_id, "explore"); + assert_eq!(rows[1].total_tokens, 150); + assert_eq!(rows[1].sessions, 1); + assert_eq!(rows[1].model_breakdown.len(), 1); + assert_eq!(rows[1].model_breakdown[0].model_id, "claude-sonnet"); + assert_eq!(rows[1].model_breakdown[0].tokens, 150); + assert_eq!(rows[1].model_breakdown[0].sessions, 1); + + assert_eq!(rows[2].agent_id, "unknown"); + assert_eq!(rows[2].total_tokens, 30); + assert_eq!(rows[2].model_breakdown.len(), 1); + assert_eq!(rows[2].model_breakdown[0].model_id, "unknown-model"); + assert_eq!(rows[2].model_breakdown[0].tokens, 30); + assert_eq!(rows[2].model_breakdown[0].sessions, 1); + } +} diff --git a/src/analytics/mod.rs b/src/analytics/mod.rs index ec4c4aa..dccd9e5 100644 --- a/src/analytics/mod.rs +++ b/src/analytics/mod.rs @@ -1,3 +1,4 @@ +pub mod agent_stats; pub mod daily; pub mod heatmap_data; pub mod model_stats; @@ -8,6 +9,7 @@ use std::collections::BTreeSet; use chrono::NaiveDate; +use crate::analytics::agent_stats::{AgentUsageRow, build_agent_chart}; use crate::analytics::daily::aggregate_daily; use crate::analytics::heatmap_data::{HeatmapData, build_heatmap_data}; use crate::analytics::model_stats::{ @@ -42,6 +44,8 @@ pub struct AnalyticsSnapshot { pub chart: ModelChartData, pub providers: Vec, pub provider_chart: ModelChartData, + pub agents: Vec, + pub agent_chart: ModelChartData, pub heatmap: HeatmapData, } @@ -79,6 +83,8 @@ pub fn build_snapshot( zero_cost_behavior, ); let heatmap = build_heatmap_data(&data.events, today); + let (agents, agent_chart) = + build_agent_chart(&filtered_events, pricing, range, today, zero_cost_behavior); let total_tokens = saturating_sum(filtered_events.iter().map(|event| event.tokens.total())); let input_tokens = saturating_sum(filtered_events.iter().map(|event| event.tokens.input)); @@ -134,6 +140,8 @@ pub fn build_snapshot( chart, providers, provider_chart, + agents, + agent_chart, heatmap, } } diff --git a/src/analytics/model_stats.rs b/src/analytics/model_stats.rs index 45c059a..902d4c1 100644 --- a/src/analytics/model_stats.rs +++ b/src/analytics/model_stats.rs @@ -234,7 +234,7 @@ pub fn chart_with_focus(chart: &ModelChartData, focused_model_id: Option<&str>) } } -fn build_chart_for_models( +pub fn build_chart_for_models( events: &[UsageEvent], top_models: &[String], range: TimeRange, @@ -421,18 +421,18 @@ fn format_tick_label(value: f64) -> String { } #[derive(Default)] -struct UsageAccumulator { - tokens: TokenUsage, - messages: usize, - prompts: usize, - sessions: BTreeSet, - active_days: BTreeSet, - cost: PriceSummary, - daily_tokens: BTreeMap, - output_rates: Vec, +pub struct UsageAccumulator { + pub tokens: TokenUsage, + pub messages: usize, + pub prompts: usize, + pub sessions: BTreeSet, + pub active_days: BTreeSet, + pub cost: PriceSummary, + pub daily_tokens: BTreeMap, + pub output_rates: Vec, } -fn median(values: &[f64]) -> f64 { +pub fn median(values: &[f64]) -> f64 { if values.is_empty() { return 0.0; } diff --git a/src/ui/agents.rs b/src/ui/agents.rs new file mode 100644 index 0000000..34129f7 --- /dev/null +++ b/src/ui/agents.rs @@ -0,0 +1,238 @@ +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::Style; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; + +use crate::analytics::AnalyticsSnapshot; +use crate::analytics::agent_stats::AgentUsageRow; +use crate::analytics::model_stats::chart_with_focus; +use crate::ui::models::{SearchItem, SearchState, layout_rows}; +use crate::ui::theme::Theme; +use crate::ui::widgets::common::{metric_line, truncate_label}; +use crate::ui::widgets::linechart::build_chart; +use crate::utils::formatting::{format_price_summary, format_tokens}; +use crate::utils::time::TimeRange; + +impl SearchItem for AgentUsageRow { + fn item_id(&self) -> &str { + &self.agent_id + } + fn item_pct(&self) -> f64 { + self.percentage + } +} + +pub fn render_agents( + frame: &mut ratatui::Frame<'_>, + area: Rect, + snapshot: &AnalyticsSnapshot, + _range: TimeRange, + focused_agent_index: usize, + search: Option<&SearchState>, + theme: &Theme, +) { + let [ + chart_area, + spacer1, + header_area, + spacer2, + detail_area, + model_area, + ] = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Fill(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(4), + Constraint::Min(0), + ]) + .areas(area); + + let effective_focus: Option = search + .and_then(|s| s.filtered_indices.get(s.selected).copied()) + .or(Some(focused_agent_index)); + + let focused_row = effective_focus.and_then(|i| snapshot.agents.get(i)); + let chart_data = chart_with_focus( + &snapshot.agent_chart, + focused_row.map(|row| row.agent_id.as_str()), + ); + frame.render_widget(build_chart(&chart_data, theme), chart_area); + + if let Some(search) = search { + frame.render_widget(Paragraph::new(""), spacer1); + super::models::render_search_overlay( + frame, + header_area, + spacer2, + detail_area, + search, + &snapshot.agents, + theme, + ); + } else if let Some(row) = focused_row { + frame.render_widget( + Paragraph::new(focus_agent_line( + row, + focused_agent_index, + &snapshot.agents, + theme, + )), + header_area, + ); + frame.render_widget(Paragraph::new(""), spacer2); + render_agent_detail(frame, detail_area, row, theme); + render_model_breakdown(frame, model_area, row, theme); + } else { + frame.render_widget(Paragraph::new(""), spacer2); + frame.render_widget( + Paragraph::new("No agent activity in this time range.").style(theme.muted_style()), + detail_area, + ); + } +} + +fn focus_agent_line( + row: &AgentUsageRow, + focused_agent_index: usize, + agents: &[AgentUsageRow], + theme: &Theme, +) -> Line<'static> { + let total = agents.len().max(1); + Line::from(vec![ + Span::styled( + format!(" โ— {}", truncate_label(&row.agent_id, 26)), + Style::default().fg(theme.series_color(focused_agent_index)), + ), + Span::styled(format!(" ({:.2}%)", row.percentage), theme.muted_style()), + Span::styled(" | ", theme.muted_style()), + Span::styled( + format!("{}/{}", focused_agent_index.min(total - 1) + 1, total), + theme.muted_style(), + ), + Span::styled(" | ", theme.muted_style()), + Span::styled("j/k โ†‘/โ†“", theme.muted_style()), + Span::styled(" | ", theme.muted_style()), + Span::styled("f find", theme.muted_style()), + ]) +} + +fn render_agent_detail( + frame: &mut ratatui::Frame<'_>, + area: Rect, + row: &AgentUsageRow, + theme: &Theme, +) { + let rows = layout_rows::<4, 2>(area); + + frame.render_widget( + Paragraph::new(metric_line( + "Total tokens: ", + format_tokens(row.total_tokens), + theme, + )), + rows[0][0], + ); + frame.render_widget( + Paragraph::new(metric_line( + "Total cost: ", + format_price_summary(&row.cost), + theme, + )), + rows[0][1], + ); + frame.render_widget( + Paragraph::new(metric_line( + "Input: ", + format_tokens(row.input_tokens), + theme, + )), + rows[1][0], + ); + frame.render_widget( + Paragraph::new(metric_line("Sessions: ", row.sessions.to_string(), theme)), + rows[1][1], + ); + frame.render_widget( + Paragraph::new(metric_line( + "Output: ", + format_tokens(row.output_tokens), + theme, + )), + rows[2][0], + ); + frame.render_widget( + Paragraph::new(metric_line( + "Active days: ", + row.active_days.to_string(), + theme, + )), + rows[2][1], + ); + frame.render_widget( + Paragraph::new(metric_line( + "Cache: ", + format_tokens(row.cache_tokens), + theme, + )), + rows[3][0], + ); + frame.render_widget( + Paragraph::new(metric_line( + "Rate: ", + format!("{:.2} tok/s", row.p50_output_tokens_per_second), + theme, + )), + rows[3][1], + ); +} + +fn render_model_breakdown( + frame: &mut ratatui::Frame<'_>, + area: Rect, + row: &AgentUsageRow, + theme: &Theme, +) { + let models = &row.model_breakdown; + let available = area.height as usize; + + if models.is_empty() || available == 0 { + return; + } + + let show_count = available.min(models.len()); + + let constraints: Vec = (0..show_count).map(|_| Constraint::Length(1)).collect(); + let lines = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(area); + + for (i, line_area) in lines.iter().enumerate() { + if i >= models.len() { + break; + } + let m = &models[i]; + + let label = crate::ui::widgets::common::truncate_label(&m.model_id, 20); + let pct = if row.total_tokens > 0 { + (m.tokens as f64 / row.total_tokens as f64) * 100.0 + } else { + 0.0 + }; + let tokens = format_tokens(m.tokens); + let cost = format_price_summary(&m.cost); + + let model_line = Line::from(vec![ + Span::styled(" ยท ", theme.muted_style()), + Span::styled(label, Style::default().fg(theme.foreground)), + Span::styled(format!(": {tokens} ({pct:.1}%)"), theme.muted_style()), + Span::styled(format!(" | sessions: {}", m.sessions), theme.muted_style()), + Span::styled(format!(" | {cost}"), theme.muted_style()), + ]); + + frame.render_widget(Paragraph::new(model_line), *line_area); + } +} diff --git a/src/ui/app.rs b/src/ui/app.rs index 7b9906d..123ac24 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -15,9 +15,10 @@ use tokio::sync::mpsc; use crate::analytics::{AnalyticsSnapshot, build_snapshot}; use crate::cache::models_cache::{PricingCatalog, refresh_remote_models}; use crate::db::models::AppData; +use crate::ui::agents::render_agents; use crate::ui::export::render_share_card; -use crate::ui::models::{SearchState, render_models, render_providers}; use crate::ui::models::MAX_QUERY_LEN; +use crate::ui::models::{SearchState, render_models, render_providers}; use crate::ui::overview::render_overview; use crate::ui::theme::{Theme, ThemeKind}; use crate::ui::widgets::common::{CONTENT_WIDTH, left_aligned_content, segment_span}; @@ -53,6 +54,7 @@ pub enum Page { Overview, Models, Providers, + Agents, } impl Page { @@ -60,15 +62,17 @@ impl Page { match self { Self::Overview => Self::Models, Self::Models => Self::Providers, - Self::Providers => Self::Overview, + Self::Providers => Self::Agents, + Self::Agents => Self::Overview, } } pub fn previous(self) -> Self { match self { - Self::Overview => Self::Providers, + Self::Overview => Self::Agents, Self::Models => Self::Overview, Self::Providers => Self::Models, + Self::Agents => Self::Providers, } } } @@ -85,6 +89,7 @@ pub struct App { status_message: Option, pub focused_model_index: usize, pub focused_provider_index: usize, + pub focused_agent_index: usize, pub search: Option, pricing_updates: mpsc::UnboundedReceiver>, clipboard_sender: mpsc::UnboundedSender, @@ -119,6 +124,7 @@ impl App { status_message: None, focused_model_index: 0, focused_provider_index: 0, + focused_agent_index: 0, search: None, pricing_updates: receiver, clipboard_sender, @@ -264,6 +270,15 @@ impl App { self.search.as_ref(), theme, ), + Page::Agents => render_agents( + frame, + body, + &self.snapshot, + self.range, + self.focused_agent_index, + self.search.as_ref(), + theme, + ), } self.render_footer(frame, footer, theme); @@ -275,6 +290,7 @@ impl App { segment_span("Overview", self.page == Page::Overview, theme), segment_span("Models", self.page == Page::Models, theme), segment_span("Providers", self.page == Page::Providers, theme), + segment_span("Agents", self.page == Page::Agents, theme), ratatui::text::Span::raw(" "), segment_span(" All ", self.range == TimeRange::All, theme), segment_span("7 Days", self.range == TimeRange::Last7Days, theme), @@ -307,6 +323,7 @@ impl App { match self.page { Page::Models => self.focused_model_index = real_idx, Page::Providers => self.focused_provider_index = real_idx, + Page::Agents => self.focused_agent_index = real_idx, _ => {} } } @@ -376,6 +393,7 @@ impl App { self.range = self.range.cycle(); self.focused_model_index = 0; self.focused_provider_index = 0; + self.focused_agent_index = 0; self.search = None; self.recompute(); } @@ -386,7 +404,7 @@ impl App { self.copy_current_page(); } KeyCode::Char('f') - if matches!(self.page, Page::Models | Page::Providers) => + if matches!(self.page, Page::Models | Page::Providers | Page::Agents) => { self.enter_search(); } @@ -395,6 +413,7 @@ impl App { self.range = range; self.focused_model_index = 0; self.focused_provider_index = 0; + self.focused_agent_index = 0; self.search = None; self.recompute(); } @@ -425,6 +444,16 @@ impl App { let next = (current + delta).rem_euclid(total) as usize; self.focused_provider_index = next; } + Page::Agents => { + if self.snapshot.agents.is_empty() { + return; + } + + let current = self.focused_agent_index as isize; + let total = self.snapshot.agents.len() as isize; + let next = (current + delta).rem_euclid(total) as usize; + self.focused_agent_index = next; + } Page::Overview => {} } } @@ -447,6 +476,14 @@ impl App { .collect::>(), self.focused_provider_index, ), + Page::Agents => ( + self.snapshot + .agents + .iter() + .map(|a| a.agent_id.clone()) + .collect::>(), + self.focused_agent_index, + ), _ => return, }; self.search = Some(SearchState::new(ids, focused)); @@ -555,6 +592,19 @@ impl App { }) .collect::>() .join("\n"), + Page::Agents => { + let mut lines = Vec::new(); + for row in self.snapshot.agents.iter().take(8) { + lines.push(format!( + "{}: {} tokens ({:.2}%)", + row.agent_id, row.total_tokens, row.percentage + )); + for m in &row.model_breakdown { + lines.push(format!(" {}: {} tokens", m.model_id, m.tokens)); + } + } + lines.join("\n") + } } } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index fad54b7..bcd7a9c 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,3 +1,4 @@ +pub mod agents; pub mod app; pub mod export; pub mod models; diff --git a/src/ui/models.rs b/src/ui/models.rs index 606ea2a..f0a0341 100644 --- a/src/ui/models.rs +++ b/src/ui/models.rs @@ -492,7 +492,7 @@ fn render_provider_detail( ); } -trait SearchItem { +pub trait SearchItem { fn item_id(&self) -> &str; fn item_pct(&self) -> f64; } @@ -515,7 +515,7 @@ impl SearchItem for ProviderUsageRow { } } -fn render_search_overlay( +pub fn render_search_overlay( frame: &mut ratatui::Frame<'_>, header_area: Rect, spacer_area: Rect, @@ -646,7 +646,7 @@ fn render_search_overlay( } } -fn layout_rows(area: Rect) -> [[Rect; COL]; ROW] { +pub fn layout_rows(area: Rect) -> [[Rect; COL]; ROW] { Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(1); ROW])