diff --git a/.github/session-navigation-v2.patch b/.github/session-navigation-v2.patch new file mode 100644 index 000000000..d288d1f46 --- /dev/null +++ b/.github/session-navigation-v2.patch @@ -0,0 +1,1846 @@ +--- a/crates/tokscale-core/src/usage_views.rs ++++ b/crates/tokscale-core/src/usage_views.rs +@@ -82,6 +82,21 @@ + pub cost: f64, + pub message_count: u32, + pub instance_count: u32, ++} ++ ++#[derive(Debug, Clone)] ++pub struct SessionUsage { ++ pub source: String, ++ pub session_id: String, ++ pub workspace_key: Option, ++ pub workspace_label: Option, ++ pub models: BTreeSet, ++ pub tokens: UsageTokenBreakdown, ++ pub cost: f64, ++ pub message_count: u32, ++ pub turn_count: u32, ++ pub first_seen: i64, ++ pub last_seen: i64, + } + + #[derive(Debug, Clone)] +@@ -180,6 +195,7 @@ + pub health: crate::source_health::HealthReport, + pub models: Vec, + pub agents: Vec, ++ pub sessions: Vec, + pub daily: Vec, + pub hourly: Vec, + pub graph: Option, +--- a/crates/tokscale-core/src/aggregate/tui.rs ++++ b/crates/tokscale-core/src/aggregate/tui.rs +@@ -5,7 +5,7 @@ + //! fold (#37). + + use std::{ +- collections::{BTreeMap, HashMap, HashSet}, ++ collections::{BTreeMap, BTreeSet, HashMap, HashSet}, + sync::Arc, + }; + +@@ -13,8 +13,8 @@ + + use crate::usage_views::{ + AgentEntry, ContributionDay, DailyModelInfo, DailySourceInfo, DailyUsage, HourlyModelInfo, +- HourlyUsage, PeriodKind, PeriodUsage, UsageData, UsageGraphData, UsageModelEntry, +- UsageTokenBreakdown, ++ HourlyUsage, PeriodKind, PeriodUsage, SessionUsage, UsageData, UsageGraphData, ++ UsageModelEntry, UsageTokenBreakdown, + }; + use crate::{ + aggregate::keys::{workspace_fields, GroupedModelKey, HourlyModelKey, IdentitySet}, +@@ -448,6 +448,7 @@ + group_by: GroupBy, + model_map: HashMap, + agent_map: HashMap, ++ session_map: HashMap<(Arc, Arc), SessionBucket>, + daily_map: HashMap, + hourly_map: HashMap, + next_sequence: usize, +@@ -483,6 +484,21 @@ + message_count: u32, + } + ++struct SessionBucket { ++ source: Arc, ++ session_id: Arc, ++ workspace_key: Option>, ++ workspace_label: Option>, ++ models: BTreeSet>, ++ tokens: UsageTokenBreakdown, ++ cost: f64, ++ message_count: u32, ++ turn_count: u32, ++ first_seen: i64, ++ last_seen: i64, ++} ++ + struct DailyBucket { + date: NaiveDate, + tokens: UsageTokenBreakdown, +@@ -562,6 +578,25 @@ + } + } + ++fn materialize_session(bucket: SessionBucket) -> SessionUsage { ++ SessionUsage { ++ source: bucket.source.to_string(), ++ session_id: bucket.session_id.to_string(), ++ workspace_key: bucket.workspace_key.map(|value| value.to_string()), ++ workspace_label: bucket.workspace_label.map(|value| value.to_string()), ++ models: bucket ++ .models ++ .into_iter() ++ .map(|model| model.to_string()) ++ .collect(), ++ tokens: bucket.tokens, ++ cost: bucket.cost, ++ message_count: bucket.message_count, ++ turn_count: bucket.turn_count, ++ first_seen: bucket.first_seen, ++ last_seen: bucket.last_seen, ++ } ++} ++ + fn materialize_daily_model(model: DailyModelBucket, group_by: &GroupBy) -> DailyModelInfo { + let provider = model.provider.to_string(); + let display_name = daily_source_model_display_name( +@@ -648,6 +683,7 @@ + group_by, + model_map: HashMap::new(), + agent_map: HashMap::new(), ++ session_map: HashMap::new(), + daily_map: HashMap::new(), + hourly_map: HashMap::new(), + next_sequence: 0, +@@ -715,6 +751,37 @@ + .sessions + .insert((Arc::clone(&msg.client), Arc::clone(&msg.session_id))); + ++ let session_entry = self ++ .session_map ++ .entry((Arc::clone(&msg.client), Arc::clone(&msg.session_id))) ++ .or_insert_with(|| SessionBucket { ++ source: Arc::clone(&msg.client), ++ session_id: Arc::clone(&msg.session_id), ++ workspace_key: msg.workspace_key.as_ref().map(Arc::clone), ++ workspace_label: msg.workspace_label.as_ref().map(Arc::clone), ++ models: BTreeSet::new(), ++ tokens: UsageTokenBreakdown::default(), ++ cost: 0.0, ++ message_count: 0, ++ turn_count: 0, ++ first_seen: msg.timestamp, ++ last_seen: msg.timestamp, ++ }); ++ if session_entry.workspace_key.is_none() { ++ session_entry.workspace_key = msg.workspace_key.as_ref().map(Arc::clone); ++ } ++ if session_entry.workspace_label.is_none() { ++ session_entry.workspace_label = msg.workspace_label.as_ref().map(Arc::clone); ++ } ++ session_entry.models.insert(Arc::clone(&msg.model_id)); ++ add_unified_tokens(&mut session_entry.tokens, &msg.tokens); ++ session_entry.cost += msg_cost; ++ session_entry.message_count = session_entry ++ .message_count ++ .saturating_add(msg.message_count.max(0) as u32); ++ if msg.is_turn_start { ++ session_entry.turn_count = session_entry.turn_count.saturating_add(1); ++ } ++ session_entry.first_seen = session_entry.first_seen.min(msg.timestamp); ++ session_entry.last_seen = session_entry.last_seen.max(msg.timestamp); ++ + if let Some(agent) = msg.agent.as_ref() { + let normalized_agent = if msg.client.as_ref() == "opencode" { + sessions::normalize_opencode_agent_name(agent) +@@ -838,6 +905,7 @@ + group_by, + model_map, + agent_map, ++ session_map, + daily_map, + hourly_map, + .. +@@ -881,6 +949,17 @@ + .then_with(|| a.agent.cmp(&b.agent)) + }); + ++ let mut sessions: Vec = ++ session_map.into_values().map(materialize_session).collect(); ++ sessions.sort_by(|left, right| { ++ right ++ .last_seen ++ .cmp(&left.last_seen) ++ .then_with(|| left.source.cmp(&right.source)) ++ .then_with(|| left.session_id.cmp(&right.session_id)) ++ }); ++ + let mut daily: Vec = daily_map + .into_values() + .map(|bucket| materialize_daily(bucket, &group_by)) +@@ -907,6 +986,7 @@ + health: Default::default(), + models, + agents, ++ sessions, + daily, + hourly, + graph: Some(graph), +@@ -1008,6 +1088,95 @@ + ) + } + ++ #[test] ++ fn session_projection_groups_by_source_and_session_before_models() { ++ let mut first = UnifiedMessage::new( ++ "codex", ++ "gpt-5.6-sol", ++ "openai", ++ "session-a", ++ 1_000, ++ crate::TokenBreakdown { ++ input: 10, ++ output: 2, ++ ..Default::default() ++ }, ++ 0.25, ++ ); ++ first.message_count = 2; ++ first.is_turn_start = true; ++ first.set_workspace( ++ Some("/repo/tokscale".to_string()), ++ Some("tokscale".to_string()), ++ ); ++ ++ let mut second = UnifiedMessage::new( ++ "codex", ++ "gpt-5.6-mini", ++ "openai", ++ "session-a", ++ 2_000, ++ crate::TokenBreakdown { ++ input: 20, ++ output: 4, ++ ..Default::default() ++ }, ++ 0.50, ++ ); ++ second.message_count = 3; ++ ++ let other_codex_session = UnifiedMessage::new( ++ "codex", ++ "gpt-5.6-sol", ++ "openai", ++ "session-b", ++ 3_000, ++ crate::TokenBreakdown { ++ input: 7, ++ output: 1, ++ ..Default::default() ++ }, ++ 0.10, ++ ); ++ let same_id_other_source = UnifiedMessage::new( ++ "claude", ++ "claude-sonnet-4", ++ "anthropic", ++ "session-a", ++ 4_000, ++ crate::TokenBreakdown { ++ input: 5, ++ output: 1, ++ ..Default::default() ++ }, ++ 0.08, ++ ); ++ ++ let data = TuiUsageHarness ++ .aggregate_messages( ++ vec![ ++ first, ++ second, ++ other_codex_session, ++ same_id_other_source, ++ ], ++ &GroupBy::WorkspaceModel, ++ ) ++ .unwrap(); ++ ++ assert_eq!(data.sessions.len(), 3); ++ let codex = data ++ .sessions ++ .iter() ++ .find(|session| session.source == "codex" && session.session_id == "session-a") ++ .unwrap(); ++ assert_eq!(codex.workspace_label.as_deref(), Some("tokscale")); ++ assert_eq!( ++ codex.models.iter().map(String::as_str).collect::>(), ++ vec!["gpt-5.6-mini", "gpt-5.6-sol"] ++ ); ++ assert_eq!(codex.tokens.total(), 36); ++ assert_eq!(codex.message_count, 5); ++ assert_eq!(codex.turn_count, 1); ++ assert_eq!(codex.first_seen, 1_000); ++ assert_eq!(codex.last_seen, 2_000); ++ } ++ + #[test] + fn test_aggregate_messages_model_grouping_uses_finalized_provider_ids() { + let loader = TuiUsageHarness; +--- a/crates/tokscale-cli/src/tui/data/mod.rs ++++ b/crates/tokscale-cli/src/tui/data/mod.rs +@@ -16,8 +16,9 @@ + // existing imports. + pub use tokscale_core::usage_views::{ + AgentEntry as AgentUsage, ContributionDay, DailyModelInfo, DailySourceInfo, DailyUsage, +- HourlyModelInfo, HourlyUsage, PeriodKind, PeriodUsage, UsageData, UsageGraphData as GraphData, +- UsageModelEntry as ModelUsage, UsageTokenBreakdown as TokenBreakdown, ++ HourlyModelInfo, HourlyUsage, PeriodKind, PeriodUsage, SessionUsage, UsageData, ++ UsageGraphData as GraphData, UsageModelEntry as ModelUsage, ++ UsageTokenBreakdown as TokenBreakdown, + }; + pub use tokscale_core::{ + aggregate_by_period, build_period_usage, find_peak_hour, +--- a/crates/tokscale-cli/src/tui/cache.rs ++++ b/crates/tokscale-cli/src/tui/cache.rs +@@ -18,11 +18,11 @@ + + use super::data::{ + AgentUsage, ContributionDay, DailyModelInfo, DailySourceInfo, DailyUsage, GraphData, +- HourlyModelInfo, HourlyUsage, ModelUsage, TokenBreakdown, UsageData, ++ HourlyModelInfo, HourlyUsage, ModelUsage, SessionUsage, TokenBreakdown, UsageData, + }; + + /// Cache staleness threshold: 5 minutes (matches TS implementation) + const CACHE_STALE_THRESHOLD_MS: u64 = 5 * 60 * 1000; +-const CACHE_SCHEMA_VERSION: u32 = 37; ++const CACHE_SCHEMA_VERSION: u32 = 38; + + #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] +@@ -127,6 +127,7 @@ + struct CachedUsageData { + models: Vec, + agents: Vec, ++ sessions: Vec, + daily: Vec, + hourly: Vec, + graph: Option, +@@ -177,6 +178,22 @@ + instance_count: u32, + } + ++#[derive(Debug, Clone, Serialize, Deserialize)] ++#[serde(rename_all = "camelCase")] ++struct CachedSessionUsage { ++ source: String, ++ session_id: String, ++ workspace_key: Option, ++ workspace_label: Option, ++ models: Vec, ++ tokens: CachedTokenBreakdown, ++ cost: f64, ++ message_count: u32, ++ turn_count: u32, ++ first_seen: i64, ++ last_seen: i64, ++} ++ + #[derive(Debug, Clone, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + struct CachedDailyModelInfo { +@@ -276,6 +293,7 @@ + struct CachedUsageDataRef<'a> { + models: CachedModelsRef<'a>, + agents: CachedAgentsRef<'a>, ++ sessions: CachedSessionsRef<'a>, + daily: CachedDailyEntriesRef<'a>, + hourly: CachedHourlyEntriesRef<'a>, + graph: Option>, +@@ -291,6 +309,7 @@ + Self { + models: CachedModelsRef(&data.models), + agents: CachedAgentsRef(&data.agents), ++ sessions: CachedSessionsRef(&data.sessions), + daily: CachedDailyEntriesRef(&data.daily), + hourly: CachedHourlyEntriesRef(&data.hourly), + graph: data.graph.as_ref().map(CachedGraphDataRef::from), +@@ -401,6 +420,49 @@ + } + } + ++struct CachedSessionsRef<'a>(&'a [SessionUsage]); ++ ++impl Serialize for CachedSessionsRef<'_> { ++ fn serialize(&self, serializer: S) -> Result ++ where ++ S: Serializer, ++ { ++ serializer.collect_seq(self.0.iter().map(CachedSessionUsageRef::from)) ++ } ++} ++ ++#[derive(Serialize)] ++#[serde(rename_all = "camelCase")] ++struct CachedSessionUsageRef<'a> { ++ source: &'a str, ++ session_id: &'a str, ++ workspace_key: Option<&'a str>, ++ workspace_label: Option<&'a str>, ++ models: &'a BTreeSet, ++ tokens: CachedTokenBreakdownRef, ++ cost: f64, ++ message_count: u32, ++ turn_count: u32, ++ first_seen: i64, ++ last_seen: i64, ++} ++ ++impl<'a> From<&'a SessionUsage> for CachedSessionUsageRef<'a> { ++ fn from(session: &'a SessionUsage) -> Self { ++ Self { ++ source: &session.source, ++ session_id: &session.session_id, ++ workspace_key: session.workspace_key.as_deref(), ++ workspace_label: session.workspace_label.as_deref(), ++ models: &session.models, ++ tokens: (&session.tokens).into(), ++ cost: session.cost, ++ message_count: session.message_count, ++ turn_count: session.turn_count, ++ first_seen: session.first_seen, ++ last_seen: session.last_seen, ++ } ++ } ++} ++ + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct CachedDailyModelInfoRef<'a> { +@@ -770,6 +832,24 @@ + } + } + ++impl From for SessionUsage { ++ fn from(session: CachedSessionUsage) -> Self { ++ Self { ++ source: session.source, ++ session_id: session.session_id, ++ workspace_key: session.workspace_key, ++ workspace_label: session.workspace_label, ++ models: session.models.into_iter().collect(), ++ tokens: session.tokens.into(), ++ cost: session.cost, ++ message_count: session.message_count, ++ turn_count: session.turn_count, ++ first_seen: session.first_seen, ++ last_seen: session.last_seen, ++ } ++ } ++} ++ + fn daily_model_info_from_cached(value: CachedDailyModelInfo) -> DailyModelInfo { + DailyModelInfo { + provider: value.provider, +@@ -922,6 +1002,7 @@ + health: u.health, + models: u.models.into_iter().map(|m| m.into()).collect(), + agents: normalize_cached_agents(u.agents)?, ++ sessions: u.sessions.into_iter().map(SessionUsage::from).collect(), + daily: daily?, + hourly: hourly?, + graph: graph.transpose()?, +@@ -1382,6 +1463,19 @@ + message_count: 4, + instance_count: 2, + }], ++ sessions: vec![SessionUsage { ++ source: "claude".to_string(), ++ session_id: "session-1".to_string(), ++ workspace_key: Some("workspace-key".to_string()), ++ workspace_label: Some("Workspace Label".to_string()), ++ models: ["claude-sonnet-4".to_string()].into_iter().collect(), ++ tokens: token_breakdown(12), ++ cost: 1.2, ++ message_count: 5, ++ turn_count: 3, ++ first_seen: 1_720_656_000_000, ++ last_seen: 1_720_659_600_000, ++ }], + daily: vec![DailyUsage { + date, + tokens: token_breakdown(41), +@@ -1468,6 +1562,12 @@ + value["data"]["hourly"][0]["datetime"], + "2026-07-11 14:05:06" + ); ++ assert_eq!(value["data"]["sessions"][0]["source"], "claude"); ++ assert_eq!(value["data"]["sessions"][0]["sessionId"], "session-1"); ++ assert_eq!( ++ value["data"]["sessions"][0]["models"], ++ serde_json::json!(["claude-sonnet-4"]) ++ ); + assert!(value["data"]["daily"][0]["sourceBreakdown"][0].is_array()); + assert!(value["data"]["daily"][0]["sourceBreakdown"][0][1]["models"][0].is_array()); + assert!(value["data"]["hourly"][0]["models"][0].is_array()); +@@ -1515,6 +1615,7 @@ + vec![ + "models", + "agents", ++ "sessions", + "daily", + "hourly", + "graph", +@@ -1559,6 +1660,25 @@ + ] + ); + ++ let ordered_session = ordered_data.field("sessions").element(0); ++ assert_eq!( ++ ordered_session.keys(), ++ vec![ ++ "source", ++ "sessionId", ++ "workspaceKey", ++ "workspaceLabel", ++ "models", ++ "tokens", ++ "cost", ++ "messageCount", ++ "turnCount", ++ "firstSeen", ++ "lastSeen", ++ ] ++ ); ++ + let ordered_daily = ordered_data.field("daily").element(0); + assert_eq!( + ordered_daily.keys(), +@@ -1784,6 +1904,7 @@ + cached_agent("Sisyphus", "opencode", u64::MAX), + cached_agent("Sisyphus", "opencode", 1), + ], ++ sessions: Vec::new(), + daily: Vec::new(), + hourly: Vec::new(), + graph: None, +--- a/crates/tokscale-cli/src/tui/app.rs ++++ b/crates/tokscale-cli/src/tui/app.rs +@@ -1,5 +1,5 @@ + use std::cell::RefCell; +-use std::collections::{BTreeMap, HashMap, HashSet}; ++use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; + use std::rc::Rc; + use std::time::{Duration, Instant}; + +@@ -15,7 +15,7 @@ + use super::colors::{get_model_color, get_provider_shade, provider_color_key}; + use super::data::{ + build_period_usage, AgentUsage, DailySourceInfo, DailyUsage, DataLoader, HourlyUsage, +- ModelUsage, PeriodKind, PeriodUsage, TokenBreakdown, UsageData, ++ ModelUsage, PeriodKind, PeriodUsage, SessionUsage, TokenBreakdown, UsageData, + }; + use super::interaction::{ + InteractionOutcome, ListInteraction, MoveCommand, TextViewport, WrapMode, +@@ -213,6 +213,27 @@ + + pub type PeriodDetailRow = DetailRow; + ++#[derive(Debug, Clone)] ++pub(crate) struct SessionSourceSummary { ++ pub source: String, ++ pub session_count: usize, ++ pub model_count: usize, ++ pub message_count: u32, ++ pub turn_count: u32, ++ pub tokens: u64, ++ pub cost: f64, ++ pub last_seen: i64, ++} ++ ++#[derive(Debug, Default)] ++struct SessionSourceAccumulator { ++ models: BTreeSet, ++ session_count: usize, ++ message_count: u32, ++ turn_count: u32, ++ tokens: u64, ++ cost: f64, ++ last_seen: i64, ++} ++ + #[derive(Debug, Clone)] + pub enum ClickAction { + Tab(Tab), +@@ -370,10 +391,9 @@ + usage_text_total_lines: usize, + pub(crate) hourly_profile_viewport: TextViewport, + hourly_profile_text_total_lines: usize, +- pub(crate) issues_viewport: TextViewport, +- issues_text_total_lines: usize, + pub selected_daily_detail_date: Option, + pub selected_period_detail: Option, ++ selected_session_source: Option, + detail_sort_contexts: HashMap, + + pub selected_graph_cell: Option<(usize, usize)>, +@@ -526,10 +546,9 @@ + usage_text_total_lines: 0, + hourly_profile_viewport: TextViewport::default(), + hourly_profile_text_total_lines: 0, +- issues_viewport: TextViewport::default(), +- issues_text_total_lines: 0, + selected_daily_detail_date: None, + selected_period_detail: None, ++ selected_session_source: None, + detail_sort_contexts: HashMap::new(), + selected_graph_cell: None, + stats_breakdown_total_lines: 0, +@@ -662,6 +681,12 @@ + self.set_current_list_interaction(self.stored_list_interaction(tab)); + } + } ++ if let Some(source) = self.selected_session_source.as_deref() { ++ if !self.data.sessions.iter().any(|session| session.source == source) { ++ self.selected_session_source = None; ++ self.set_current_list_interaction(self.stored_list_interaction(Tab::Issues)); ++ } ++ } + + self.clamp_selection(); + super::data::trim_allocator(); +@@ -697,6 +722,7 @@ + !self.data.models.is_empty() + || !self.data.daily.is_empty() + || !self.data.agents.is_empty() ++ || !self.data.sessions.is_empty() + || self.data.graph.is_some() + || self.data.total_tokens > 0 + || self.data.total_cost > 0.0 +@@ -921,6 +947,9 @@ + KeyCode::Enter if self.current_tab == Tab::Stats => { + self.handle_graph_selection(); + } ++ KeyCode::Enter if self.current_tab == Tab::Issues => { ++ self.open_selected_session_source(); ++ } + KeyCode::Esc | KeyCode::Backspace + if self.current_tab == Tab::Daily && self.is_daily_detail_active() => + { +@@ -931,6 +960,11 @@ + { + self.close_period_detail(); + } ++ KeyCode::Esc | KeyCode::Backspace ++ if self.current_tab == Tab::Issues && self.is_session_detail_active() => ++ { ++ self.close_session_detail(); ++ } + KeyCode::Esc if self.selected_graph_cell.is_some() => { + self.selected_graph_cell = None; + self.stats_breakdown_total_lines = 0; +@@ -1078,33 +1112,16 @@ + .visible_range(self.hourly_profile_text_total_lines) + } + +- pub(crate) fn set_issues_text_viewport(&mut self, visible: usize, total_lines: usize) { +- self.issues_text_total_lines = total_lines; +- self.issues_viewport.set_visible(visible, total_lines); +- } +- +- pub(crate) fn issues_text_visible_range(&self) -> std::ops::Range { +- self.issues_viewport +- .visible_range(self.issues_text_total_lines) +- } +- + fn active_text_viewport_mut(&mut self) -> Option<&mut TextViewport> { + match self.current_tab { + Tab::Usage => Some(&mut self.usage_viewport), + Tab::Hourly if self.hourly_view_mode == HourlyViewMode::Profile => { + Some(&mut self.hourly_profile_viewport) + } +- Tab::Issues => Some(&mut self.issues_viewport), + _ => None, + } + } +@@ -1115,7 +1132,6 @@ + Tab::Hourly if self.hourly_view_mode == HourlyViewMode::Profile => { + Some(self.hourly_profile_text_total_lines) + } +- Tab::Issues => Some(self.issues_text_total_lines), + _ => None, + } + } +@@ -1159,6 +1175,9 @@ + if self.current_period_kind().is_some() && self.is_period_detail_active() { + return false; + } ++ if self.current_tab == Tab::Issues && self.is_session_detail_active() { ++ return false; ++ } + true + } + +@@ -1243,6 +1262,9 @@ + self.selected_graph_cell = None; + self.stats_breakdown_total_lines = 0; + } ++ if target != Tab::Issues { ++ self.selected_session_source = None; ++ } + + let (field, dir) = self + .tab_sort_state +@@ -1259,10 +1281,10 @@ + fn default_sort_for_tab(tab: Tab) -> (SortField, SortDirection) { + match tab { + Tab::Models => (SortField::Tokens, SortDirection::Descending), +- Tab::Monthly | Tab::Weekly | Tab::Daily | Tab::Hourly => { ++ Tab::Monthly | Tab::Weekly | Tab::Daily | Tab::Hourly | Tab::Issues => { + (SortField::Date, SortDirection::Descending) + } +- Tab::Overview | Tab::Usage | Tab::Stats | Tab::Agents | Tab::Issues => { ++ Tab::Overview | Tab::Usage | Tab::Stats | Tab::Agents => { + (SortField::Cost, SortDirection::Descending) + } + } +@@ -1485,7 +1507,8 @@ + .iter() + .map(|u| u.metrics.len()) + .sum(), +- Tab::Issues => 0, ++ Tab::Issues if self.is_session_detail_active() => self.get_sorted_sessions().len(), ++ Tab::Issues => self.get_sorted_session_sources().len(), + } + } + +@@ -1721,6 +1744,132 @@ + self.clamp_selection(); + } + ++ pub(crate) fn is_session_detail_active(&self) -> bool { ++ self.selected_session_source.is_some() ++ } ++ ++ pub(crate) fn selected_session_source(&self) -> Option<&str> { ++ self.selected_session_source.as_deref() ++ } ++ ++ fn open_selected_session_source(&mut self) { ++ if self.is_session_detail_active() { ++ return; ++ } ++ ++ let selected = self ++ .get_sorted_session_sources() ++ .get(self.selected_index) ++ .map(|source| source.source.clone()); ++ let Some(source) = selected else { ++ return; ++ }; ++ ++ self.persist_list_interaction_for(Tab::Issues); ++ self.selected_session_source = Some(source.clone()); ++ self.selected_index = 0; ++ self.scroll_offset = 0; ++ self.set_local_report_status(&format!("Viewing sessions for {source}")); ++ self.clamp_selection(); ++ } ++ ++ fn close_session_detail(&mut self) { ++ let Some(source) = self.selected_session_source.take() else { ++ return; ++ }; ++ ++ let source_interaction = self.stored_list_interaction(Tab::Issues); ++ let restored_index = self ++ .get_sorted_session_sources() ++ .iter() ++ .position(|summary| summary.source == source) ++ .unwrap_or(source_interaction.selected); ++ let max_visible = source_interaction.visible.max(1); ++ let viewport_still_holds = restored_index >= source_interaction.scroll ++ && restored_index < source_interaction.scroll + max_visible; ++ let scroll = if viewport_still_holds { ++ source_interaction.scroll ++ } else { ++ restored_index.saturating_sub(max_visible / 2) ++ }; ++ self.set_current_list_interaction(ListInteraction { ++ selected: restored_index, ++ scroll, ++ visible: source_interaction.visible, ++ }); ++ self.set_local_report_status("Returned to session sources"); ++ self.clamp_selection(); ++ } ++ ++ pub(crate) fn get_sorted_session_sources(&self) -> Vec { ++ let mut sources = BTreeMap::::new(); ++ for session in &self.data.sessions { ++ let entry = sources.entry(session.source.clone()).or_default(); ++ entry.session_count = entry.session_count.saturating_add(1); ++ entry.models.extend(session.models.iter().cloned()); ++ entry.message_count = entry.message_count.saturating_add(session.message_count); ++ entry.turn_count = entry.turn_count.saturating_add(session.turn_count); ++ entry.tokens = entry ++ .tokens ++ .checked_add(session.tokens.total()) ++ .expect("session source token total exceeds u64::MAX"); ++ entry.cost += session.cost; ++ entry.last_seen = entry.last_seen.max(session.last_seen); ++ } ++ ++ let mut rows = sources ++ .into_iter() ++ .map(|(source, aggregate)| SessionSourceSummary { ++ source, ++ session_count: aggregate.session_count, ++ model_count: aggregate.models.len(), ++ message_count: aggregate.message_count, ++ turn_count: aggregate.turn_count, ++ tokens: aggregate.tokens, ++ cost: aggregate.cost, ++ last_seen: aggregate.last_seen, ++ }) ++ .collect::>(); ++ rows.sort_by(|left, right| { ++ let ordering = match self.sort_field { ++ SortField::Date => left.last_seen.cmp(&right.last_seen), ++ SortField::Tokens => left.tokens.cmp(&right.tokens), ++ SortField::Cost => left.cost.total_cmp(&right.cost), ++ }; ++ let ordering = match self.sort_direction { ++ SortDirection::Ascending => ordering, ++ SortDirection::Descending => ordering.reverse(), ++ }; ++ ordering.then_with(|| left.source.cmp(&right.source)) ++ }); ++ rows ++ } ++ ++ pub(crate) fn get_sorted_sessions(&self) -> Vec { ++ let Some(source) = self.selected_session_source.as_deref() else { ++ return Vec::new(); ++ }; ++ let mut sessions = self ++ .data ++ .sessions ++ .iter() ++ .filter(|session| session.source == source) ++ .cloned() ++ .collect::>(); ++ sessions.sort_by(|left, right| { ++ let ordering = match self.sort_field { ++ SortField::Date => left.last_seen.cmp(&right.last_seen), ++ SortField::Tokens => left.tokens.total().cmp(&right.tokens.total()), ++ SortField::Cost => left.cost.total_cmp(&right.cost), ++ }; ++ let ordering = match self.sort_direction { ++ SortDirection::Ascending => ordering, ++ SortDirection::Descending => ordering.reverse(), ++ }; ++ ordering.then_with(|| left.session_id.cmp(&right.session_id)) ++ }); ++ sessions ++ } ++ + fn toggle_auto_refresh(&mut self) { + self.auto_refresh = !self.auto_refresh; + if self.auto_refresh { +@@ -1878,7 +2027,28 @@ + h.cost + ) + }), +- Tab::Stats | Tab::Usage | Tab::Issues => None, ++ Tab::Issues if self.is_session_detail_active() => self ++ .get_sorted_sessions() ++ .get(self.selected_index) ++ .map(|session| { ++ format!( ++ "{} / {}: {} tokens, ${:.4}", ++ session.source, ++ session.session_id, ++ session.tokens.total(), ++ session.cost ++ ) ++ }), ++ Tab::Issues => self ++ .get_sorted_session_sources() ++ .get(self.selected_index) ++ .map(|source| { ++ format!( ++ "{}: {} sessions, {} tokens, ${:.4}", ++ source.source, source.session_count, source.tokens, source.cost ++ ) ++ }), ++ Tab::Stats | Tab::Usage => None, + }; + + if let Some(text) = text { +@@ -2561,7 +2731,34 @@ + app + } + ++ fn session_usage( ++ source: &str, ++ session_id: &str, ++ models: &[&str], ++ last_seen: i64, ++ tokens: u64, ++ cost: f64, ++ ) -> SessionUsage { ++ SessionUsage { ++ source: source.to_string(), ++ session_id: session_id.to_string(), ++ workspace_key: Some(format!("/repo/{session_id}")), ++ workspace_label: Some(format!("workspace-{session_id}")), ++ models: models.iter().map(|model| (*model).to_string()).collect(), ++ tokens: TokenBreakdown { ++ input: tokens, ++ ..Default::default() ++ }, ++ cost, ++ message_count: 3, ++ turn_count: 2, ++ first_seen: last_seen.saturating_sub(1_000), ++ last_seen, ++ } ++ } ++ + fn daily_usage(date: &str, cost: f64, models: Vec<(&str, &str, f64)>) -> DailyUsage { + daily_usage_by_source(date, cost, vec![("claude", models)]) + } +@@ -3335,6 +3532,93 @@ + assert!((rows[0].cost - 7.0).abs() < f64::EPSILON); + } + ++ #[test] ++ fn sessions_drill_down_from_source_to_real_session_rows() { ++ let mut app = make_app(); ++ app.current_tab = Tab::Issues; ++ app.sort_field = SortField::Date; ++ app.sort_direction = SortDirection::Descending; ++ app.data.sessions = vec![ ++ session_usage( ++ "codex", ++ "session-a", ++ &["gpt-5.6-mini", "gpt-5.6-sol"], ++ 3_000, ++ 36, ++ 0.75, ++ ), ++ session_usage("codex", "session-b", &["gpt-5.6-sol"], 2_000, 8, 0.10), ++ session_usage( ++ "claude", ++ "session-a", ++ &["claude-sonnet-4"], ++ 1_000, ++ 6, ++ 0.08, ++ ), ++ ]; ++ ++ let sources = app.get_sorted_session_sources(); ++ assert_eq!(sources.len(), 2); ++ assert_eq!(sources[0].source, "codex"); ++ assert_eq!(sources[0].session_count, 2); ++ assert_eq!(sources[0].model_count, 2); ++ ++ app.selected_index = 0; ++ app.handle_key_event(key(KeyCode::Enter)); ++ ++ assert!(app.is_session_detail_active()); ++ assert_eq!(app.selected_session_source(), Some("codex")); ++ assert_eq!(app.get_current_list_len(), 2); ++ let sessions = app.get_sorted_sessions(); ++ assert_eq!(sessions[0].session_id, "session-a"); ++ assert_eq!( ++ sessions[0] ++ .models ++ .iter() ++ .map(String::as_str) ++ .collect::>(), ++ vec!["gpt-5.6-mini", "gpt-5.6-sol"] ++ ); ++ ++ app.handle_key_event(key(KeyCode::Esc)); ++ ++ assert!(!app.is_session_detail_active()); ++ assert_eq!(app.selected_index, 0); ++ assert_eq!(app.get_current_list_len(), 2); ++ } ++ ++ #[test] ++ fn session_detail_closes_when_refresh_removes_selected_source() { ++ let mut app = make_app(); ++ app.current_tab = Tab::Issues; ++ app.data.sessions = vec![session_usage( ++ "codex", ++ "session-a", ++ &["gpt-5.6-sol"], ++ 3_000, ++ 12, ++ 0.25, ++ )]; ++ app.handle_key_event(key(KeyCode::Enter)); ++ assert!(app.is_session_detail_active()); ++ ++ app.update_data(UsageData { ++ sessions: vec![session_usage( ++ "claude", ++ "session-b", ++ &["claude-sonnet-4"], ++ 4_000, ++ 8, ++ 0.10, ++ )], ++ ..Default::default() ++ }); ++ ++ assert!(!app.is_session_detail_active()); ++ assert_eq!(app.get_current_list_len(), 1); ++ assert_eq!(app.get_sorted_session_sources()[0].source, "claude"); ++ } ++ + // ── handle_key_event: sort ────────────────────────────────────── + + #[test] +@@ -3724,24 +4008,6 @@ + assert_eq!(app.scroll_offset, 1); + } + +- #[test] +- fn issues_tab_key_scrolls_text_viewport_without_table_selection() { +- let mut app = make_app(); +- app.current_tab = Tab::Issues; +- app.selected_index = 2; +- app.scroll_offset = 1; +- app.set_issues_text_viewport(4, 10); +- +- app.handle_key_event(key(KeyCode::PageDown)); +- +- assert_eq!(app.issues_viewport.scroll, 2); +- assert_eq!(app.selected_index, 2); +- assert_eq!(app.scroll_offset, 1); +- } +- + #[test] + fn usage_tab_mouse_wheel_scrolls_text_viewport_without_table_selection() { + let mut app = make_app_with_usage(); +--- a/crates/tokscale-cli/src/tui/ui/issues.rs ++++ b/crates/tokscale-cli/src/tui/ui/issues.rs +@@ -1,16 +1,15 @@ +-use std::collections::{BTreeMap, BTreeSet}; ++use std::collections::BTreeMap; + ++use chrono::{Local, TimeZone}; + use ratatui::prelude::*; + use ratatui::widgets::{ + Block, Borders, Cell, Paragraph, Row, Scrollbar, ScrollbarOrientation, Table, + }; + +-use crate::tui::app::{App, SortDirection, SortField}; ++use crate::tui::app::App; + + use super::widgets::{ + format_cost, format_tokens, get_client_display_name, truncate_display_width, + viewport_scrollbar_state, + }; + +-#[derive(Debug, Clone)] +-struct SessionCoverageRow { +- harness: String, +- model: String, +- sessions: u32, +- tokens: u64, +- cost: f64, +-} +- +-#[derive(Debug, Clone, Default)] +-struct HarnessCoverage { +- tokens: u64, +- cost: f64, +- models: BTreeSet, +- active_days: BTreeSet, +-} +- + pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { + if area.is_empty() { + return; + } + +- if area.height < 10 { +- render_session_coverage(frame, app, area); +- return; ++ if app.is_session_detail_active() { ++ render_session_detail(frame, app, area); ++ } else { ++ render_source_index(frame, app, area); + } ++} + +- let health_height = if area.height >= 18 { 9 } else { 6 }; ++fn panel_block<'a>(app: &App, title: &'a str) -> Block<'a> { ++ Block::default() ++ .borders(Borders::ALL) ++ .border_style(Style::default().fg(app.theme.border)) ++ .title(Span::styled( ++ format!(" {title} "), ++ Style::default() ++ .fg(app.theme.accent) ++ .add_modifier(Modifier::BOLD), ++ )) ++ .style(Style::default().bg(app.theme.background)) ++} ++ ++fn render_source_index(frame: &mut Frame, app: &mut App, area: Rect) { ++ if area.height < 10 { ++ render_source_table(frame, app, area); ++ return; ++ } ++ ++ let health_height = if area.height >= 15 { 5 } else { 4 }; + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ +@@ -18,104 +17,419 @@ + Constraint::Length(health_height.min(area.height)), + ]) + .split(area); +- render_session_coverage(frame, app, chunks[0]); +- render_harness_health(frame, app, chunks[1]); +-} + +-fn panel_block<'a>(app: &App, title: &'a str) -> Block<'a> { +- Block::default() +- .borders(Borders::ALL) +- .border_style(Style::default().fg(app.theme.border)) +- .title(Span::styled( +- format!(" {title} "), +- Style::default() +- .fg(app.theme.accent) +- .add_modifier(Modifier::BOLD), +- )) +- .style(Style::default().bg(app.theme.background)) ++ render_source_table(frame, app, chunks[0]); ++ render_source_health(frame, app, chunks[1]); + } + +-fn session_coverage_rows(app: &App) -> Vec { +- let mut rows = app +- .data +- .models +- .iter() +- .map(|model| SessionCoverageRow { +- harness: display_harnesses(&model.client), +- model: model +- .workspace_label +- .as_ref() +- .map(|workspace| format!("{workspace} / {}", model.model)) +- .unwrap_or_else(|| model.model.clone()), +- sessions: model.session_count, +- tokens: model.tokens.total(), +- cost: model.cost, +- }) +- .collect::>(); +- +- rows.sort_by(|left, right| { +- let ordering = match app.sort_field { +- SortField::Cost => left.cost.total_cmp(&right.cost), +- SortField::Tokens => left.tokens.cmp(&right.tokens), +- SortField::Date => left.sessions.cmp(&right.sessions), +- }; +- let ordering = match app.sort_direction { +- SortDirection::Ascending => ordering, +- SortDirection::Descending => ordering.reverse(), +- }; +- ordering +- .then_with(|| left.harness.cmp(&right.harness)) +- .then_with(|| left.model.cmp(&right.model)) +- }); +- rows +-} +- +-fn display_harnesses(raw: &str) -> String { +- raw.split(", ") +- .map(get_client_display_name) +- .collect::>() +- .join(", ") +-} +- +-fn render_session_coverage(frame: &mut Frame, app: &mut App, area: Rect) { +- let rows = session_coverage_rows(app); +- let session_links = rows.iter().fold(0u64, |total, row| { +- total.saturating_add(u64::from(row.sessions)) +- }); +- let block = panel_block(app, "Session Coverage").title_top( ++fn render_source_table(frame: &mut Frame, app: &mut App, area: Rect) { ++ let rows = app.get_sorted_session_sources(); ++ let total_sessions = app.data.sessions.len(); ++ let block = panel_block(app, "Code Agents / Sources").title_top( + Line::from(Span::styled( +- format!(" {session_links} model-session links "), ++ format!(" {} sources · {total_sessions} sessions ", rows.len()), + Style::default().fg(app.theme.muted), + )) + .right_aligned(), +@@ -125,119 +439,70 @@ + } + + if rows.is_empty() { +- app.set_issues_text_viewport(inner.height as usize, 0); ++ app.set_max_visible_items(1); + frame.render_widget( +- Paragraph::new("No session coverage data available") ++ Paragraph::new("No session data available") + .style(Style::default().fg(app.theme.muted)) + .alignment(Alignment::Center), + inner, +@@ -245,44 +510,14 @@ + } + + let visible_height = inner.height.saturating_sub(1) as usize; +- app.set_issues_text_viewport(visible_height, rows.len()); +- let visible_rows = rows[app.issues_text_visible_range()] ++ app.set_max_visible_items(visible_height.max(1)); ++ let start = app.scroll_offset.min(rows.len()); ++ let end = start.saturating_add(visible_height).min(rows.len()); ++ let wide = inner.width >= 105; ++ let medium = inner.width >= 72; ++ ++ let table_rows = rows[start..end] + .iter() +- .map(|row| { +- Row::new(vec![ +- Cell::from(truncate_display_width(&row.harness, 24)) +- .style(Style::default().fg(app.theme.muted)), +- Cell::from(truncate_display_width(&row.model, 38)) +- .style(Style::default().fg(app.theme.foreground)), +- Cell::from(Line::from(row.sessions.to_string()).centered()) +- .style(Style::default().fg(Color::Cyan)), +- Cell::from(Line::from(format_tokens(row.tokens)).right_aligned()) +- .style(Style::default().fg(Color::Cyan)), +- Cell::from(Line::from(format_cost(row.cost)).right_aligned()) +- .style(Style::default().fg(Color::Green)), +- ]) +- }) +- .collect::>(); +- let header = Row::new(vec![ +- "Harness", +- "Model / Workspace", +- "Sessions", +- "Tokens", +- "Cost", +- ]) +- .style( +- Style::default() +- .fg(app.theme.accent) +- .add_modifier(Modifier::BOLD), +- ) +- .height(1); +- let widths = if app.is_narrow() { +- [ +- Constraint::Percentage(24), +- Constraint::Percentage(38), +- Constraint::Length(9), +- Constraint::Length(11), +- Constraint::Length(10), +- ] ++ .enumerate() ++ .map(|(offset, row)| { ++ let index = start + offset; ++ let selected = index == app.selected_index; ++ let marker = if selected { "▶ " } else { " " }; ++ let source = format!( ++ "{marker}{}", ++ truncate_display_width(&get_client_display_name(&row.source), 24) ++ ); ++ ++ let mut cells = vec![ ++ Cell::from(source).style(value_style(app, selected, app.theme.foreground)), ++ centered_cell(app, selected, row.session_count.to_string(), Color::Cyan), ++ centered_cell(app, selected, row.model_count.to_string(), Color::Cyan), ++ ]; ++ if medium { ++ cells.push(centered_cell( ++ app, ++ selected, ++ row.message_count.to_string(), ++ Color::Cyan, ++ )); ++ } ++ if wide { ++ cells.push(centered_cell( ++ app, ++ selected, ++ row.turn_count.to_string(), ++ Color::Cyan, ++ )); ++ } ++ cells.push(right_cell( ++ app, ++ selected, ++ format_tokens(row.tokens), ++ Color::Cyan, ++ )); ++ cells.push(right_cell( ++ app, ++ selected, ++ format_cost(row.cost), ++ Color::Green, ++ )); ++ if wide { ++ cells.push(right_cell( ++ app, ++ selected, ++ format_timestamp(row.last_seen, true), ++ app.theme.muted, ++ )); ++ } ++ Row::new(cells) ++ }) ++ .collect::>(); ++ ++ let (header, widths) = if wide { ++ ( ++ Row::new(vec![ ++ "Code Agent / Source", ++ "Sessions", ++ "Models", ++ "Messages", ++ "Turns", ++ "Tokens", ++ "Cost", ++ "Last Active", ++ ]), ++ vec![ ++ Constraint::Min(22), ++ Constraint::Length(9), ++ Constraint::Length(7), ++ Constraint::Length(9), ++ Constraint::Length(7), ++ Constraint::Length(12), ++ Constraint::Length(11), ++ Constraint::Length(14), ++ ], ++ ) ++ } else if medium { ++ ( ++ Row::new(vec![ ++ "Code Agent / Source", ++ "Sessions", ++ "Models", ++ "Messages", ++ "Tokens", ++ "Cost", ++ ]), ++ vec![ ++ Constraint::Min(20), ++ Constraint::Length(9), ++ Constraint::Length(7), ++ Constraint::Length(9), ++ Constraint::Length(12), ++ Constraint::Length(11), ++ ], ++ ) + } else { +- [ +- Constraint::Percentage(24), +- Constraint::Percentage(42), +- Constraint::Length(10), +- Constraint::Length(13), +- Constraint::Length(12), +- ] ++ ( ++ Row::new(vec![ ++ "Code Agent / Source", ++ "Sessions", ++ "Models", ++ "Tokens", ++ "Cost", ++ ]), ++ vec![ ++ Constraint::Min(18), ++ Constraint::Length(8), ++ Constraint::Length(7), ++ Constraint::Length(11), ++ Constraint::Length(10), ++ ], ++ ) + }; +- frame.render_widget(Table::new(visible_rows, widths).header(header), inner); + +- if rows.len() > visible_height { +- let mut state = viewport_scrollbar_state( +- rows.len(), +- app.issues_viewport.scroll, +- visible_height.max(1), +- ); +- frame.render_stateful_widget( +- Scrollbar::new(ScrollbarOrientation::VerticalRight) +- .begin_symbol(Some("▲")) +- .end_symbol(Some("▼")), +- area.inner(Margin { +- horizontal: 0, +- vertical: 1, +- }), +- &mut state, +- ); +- } ++ frame.render_widget( ++ Table::new(table_rows, widths) ++ .header(header_style(app, header)) ++ .column_spacing(1), ++ inner, ++ ); ++ render_scrollbar( ++ frame, ++ area, ++ rows.len(), ++ visible_height, ++ app.scroll_offset, ++ ); + } + +-fn harness_coverage(app: &App) -> BTreeMap { +- let mut harnesses = BTreeMap::::new(); +- for day in &app.data.daily { +- for (harness, source) in &day.source_breakdown { +- let entry = harnesses.entry(harness.clone()).or_default(); +- entry.tokens = entry +- .tokens +- .checked_add(source.tokens.total()) +- .expect("harness coverage token total exceeds u64::MAX"); +- entry.cost += source.cost; +- if source.tokens.total() > 0 { +- entry.active_days.insert(day.date); +- } +- for (model_key, model) in &source.models { +- entry.models.insert(if model.color_key.is_empty() { +- model_key.clone() +- } else { +- model.color_key.clone() +- }); +- } +- } ++fn render_session_detail(frame: &mut Frame, app: &mut App, area: Rect) { ++ let source = app.selected_session_source().unwrap_or_default().to_string(); ++ let display_source = get_client_display_name(&source); ++ let rows = app.get_sorted_sessions(); ++ let title = format!("Sessions / {display_source}"); ++ let block = panel_block(app, &title).title_top( ++ Line::from(Span::styled( ++ format!(" {} sessions ", rows.len()), ++ Style::default().fg(app.theme.muted), ++ )) ++ .right_aligned(), ++ ); ++ let inner = block.inner(area); ++ frame.render_widget(block, area); ++ if inner.is_empty() { ++ return; + } +- harnesses ++ ++ if rows.is_empty() { ++ app.set_max_visible_items(1); ++ frame.render_widget( ++ Paragraph::new("No sessions available for this source") ++ .style(Style::default().fg(app.theme.muted)) ++ .alignment(Alignment::Center), ++ inner, ++ ); ++ return; ++ } ++ ++ let visible_height = inner.height.saturating_sub(1) as usize; ++ app.set_max_visible_items(visible_height.max(1)); ++ let start = app.scroll_offset.min(rows.len()); ++ let end = start.saturating_add(visible_height).min(rows.len()); ++ let wide = inner.width >= 128; ++ let medium = inner.width >= 90; ++ ++ let table_rows = rows[start..end] ++ .iter() ++ .enumerate() ++ .map(|(offset, session)| { ++ let index = start + offset; ++ let selected = index == app.selected_index; ++ let marker = if selected { "▶ " } else { " " }; ++ let session_id = format!( ++ "{marker}{}", ++ truncate_display_width(&session.session_id, 30) ++ ); ++ let workspace = session ++ .workspace_label ++ .as_deref() ++ .or(session.workspace_key.as_deref()) ++ .unwrap_or("—"); ++ let models = session.models.iter().cloned().collect::>().join(", "); ++ ++ let mut cells = vec![ ++ Cell::from(session_id).style(value_style(app, selected, app.theme.foreground)), ++ Cell::from(truncate_display_width(workspace, 26)) ++ .style(value_style(app, selected, app.theme.muted)), ++ Cell::from(truncate_display_width(&models, 34)) ++ .style(value_style(app, selected, app.theme.foreground)), ++ ]; ++ if medium { ++ cells.push(centered_cell( ++ app, ++ selected, ++ session.message_count.to_string(), ++ Color::Cyan, ++ )); ++ cells.push(centered_cell( ++ app, ++ selected, ++ session.turn_count.to_string(), ++ Color::Cyan, ++ )); ++ } ++ cells.push(right_cell( ++ app, ++ selected, ++ format_tokens(session.tokens.total()), ++ Color::Cyan, ++ )); ++ if medium { ++ cells.push(right_cell( ++ app, ++ selected, ++ format_cost(session.cost), ++ Color::Green, ++ )); ++ } ++ if wide { ++ cells.push(right_cell( ++ app, ++ selected, ++ format_timestamp(session.first_seen, true), ++ app.theme.muted, ++ )); ++ } ++ cells.push(right_cell( ++ app, ++ selected, ++ format_timestamp(session.last_seen, true), ++ app.theme.muted, ++ )); ++ Row::new(cells) ++ }) ++ .collect::>(); ++ ++ let (header, widths) = if wide { ++ ( ++ Row::new(vec![ ++ "Session", ++ "Workspace", ++ "Models", ++ "Messages", ++ "Turns", ++ "Tokens", ++ "Cost", ++ "First Seen", ++ "Last Active", ++ ]), ++ vec![ ++ Constraint::Min(18), ++ Constraint::Length(22), ++ Constraint::Min(22), ++ Constraint::Length(9), ++ Constraint::Length(7), ++ Constraint::Length(12), ++ Constraint::Length(11), ++ Constraint::Length(14), ++ Constraint::Length(14), ++ ], ++ ) ++ } else if medium { ++ ( ++ Row::new(vec![ ++ "Session", ++ "Workspace", ++ "Models", ++ "Messages", ++ "Turns", ++ "Tokens", ++ "Cost", ++ "Last Active", ++ ]), ++ vec![ ++ Constraint::Min(18), ++ Constraint::Length(20), ++ Constraint::Min(20), ++ Constraint::Length(9), ++ Constraint::Length(7), ++ Constraint::Length(12), ++ Constraint::Length(11), ++ Constraint::Length(14), ++ ], ++ ) ++ } else { ++ ( ++ Row::new(vec!["Session", "Workspace", "Models", "Tokens", "Last Active"]), ++ vec![ ++ Constraint::Min(16), ++ Constraint::Length(18), ++ Constraint::Min(18), ++ Constraint::Length(11), ++ Constraint::Length(14), ++ ], ++ ) ++ }; ++ ++ frame.render_widget( ++ Table::new(table_rows, widths) ++ .header(header_style(app, header)) ++ .column_spacing(1), ++ inner, ++ ); ++ render_scrollbar( ++ frame, ++ area, ++ rows.len(), ++ visible_height, ++ app.scroll_offset, ++ ); + } + +-fn render_harness_health(frame: &mut Frame, app: &App, area: Rect) { +- let block = panel_block(app, "Harnesses & Source Health"); ++fn header_style<'a>(app: &App, header: Row<'a>) -> Row<'a> { ++ header ++ .style( ++ Style::default() ++ .fg(app.theme.accent) ++ .add_modifier(Modifier::BOLD), ++ ) ++ .height(1) ++} ++ ++fn value_style(app: &App, selected: bool, color: Color) -> Style { ++ if selected { ++ Style::default() ++ .fg(app.theme.background) ++ .bg(app.theme.accent) ++ .add_modifier(Modifier::BOLD) ++ } else { ++ Style::default().fg(color) ++ } ++} ++ ++fn centered_cell(app: &App, selected: bool, value: String, color: Color) -> Cell<'static> { ++ Cell::from(Line::from(value).centered()).style(value_style(app, selected, color)) ++} ++ ++fn right_cell(app: &App, selected: bool, value: String, color: Color) -> Cell<'static> { ++ Cell::from(Line::from(value).right_aligned()).style(value_style(app, selected, color)) ++} ++ ++fn render_source_health(frame: &mut Frame, app: &App, area: Rect) { ++ let block = panel_block(app, "Source Health"); + let inner = block.inner(area); + frame.render_widget(block, area); + if inner.is_empty() { +@@ -298,7 +664,7 @@ + ), + ]), + ]; + + if !health.issues.is_empty() { +@@ -316,49 +682,9 @@ + .collect::>() + .join(", "); + lines.push(Line::from(vec![ +- Span::styled( +- "Affected source groups: ", +- Style::default().fg(app.theme.muted), +- ), ++ Span::styled("Affected: ", Style::default().fg(app.theme.muted)), + Span::styled(labels, Style::default().fg(Color::Yellow)), + ])); + } + +- let mut harnesses = harness_coverage(app).into_iter().collect::>(); +- harnesses.sort_by(|(left_name, left), (right_name, right)| { +- right +- .tokens +- .cmp(&left.tokens) +- .then_with(|| right.cost.total_cmp(&left.cost)) +- .then_with(|| left_name.cmp(right_name)) +- }); +- for (harness, coverage) in harnesses { +- lines.push(Line::from(vec![ +- Span::styled( +- format!("{} ", get_client_display_name(&harness)), +- Style::default() +- .fg(app.theme.foreground) +- .add_modifier(Modifier::BOLD), +- ), +- Span::styled( +- format!("{} models", coverage.models.len()), +- Style::default().fg(app.theme.muted), +- ), +- Span::styled(" · ", Style::default().fg(app.theme.muted)), +- Span::styled( +- format!("{} active days", coverage.active_days.len()), +- Style::default().fg(app.theme.muted), +- ), +- Span::styled(" · ", Style::default().fg(app.theme.muted)), +- Span::styled( +- format_tokens(coverage.tokens), +- Style::default().fg(Color::Cyan), +- ), +- Span::styled(" · ", Style::default().fg(app.theme.muted)), +- Span::styled( +- format_cost(coverage.cost), +- Style::default().fg(Color::Green), +- ), +- ])); +- } +- + frame.render_widget( + Paragraph::new( + lines +@@ -370,6 +696,55 @@ + ); + } + ++fn render_scrollbar( ++ frame: &mut Frame, ++ area: Rect, ++ total: usize, ++ visible: usize, ++ scroll: usize, ++) { ++ if total <= visible || visible == 0 { ++ return; ++ } ++ ++ let mut state = viewport_scrollbar_state(total, scroll, visible); ++ frame.render_stateful_widget( ++ Scrollbar::new(ScrollbarOrientation::VerticalRight) ++ .begin_symbol(Some("▲")) ++ .end_symbol(Some("▼")), ++ area.inner(Margin { ++ horizontal: 0, ++ vertical: 1, ++ }), ++ &mut state, ++ ); ++} ++ ++fn format_timestamp(timestamp_ms: i64, compact: bool) -> String { ++ if timestamp_ms <= 0 { ++ return "—".to_string(); ++ } ++ let Some(datetime) = Local.timestamp_millis_opt(timestamp_ms).single() else { ++ return "—".to_string(); ++ }; ++ if compact { ++ datetime.format("%m-%d %H:%M").to_string() ++ } else { ++ datetime.format("%Y-%m-%d %H:%M").to_string() ++ } ++} ++ + fn format_bytes(bytes: u64) -> String { + const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; + let mut value = bytes as f64; +@@ -389,9 +764,10 @@ + mod tests { + use super::*; + + #[test] +- fn combined_harness_names_are_displayed_individually() { +- assert_eq!(display_harnesses("claude, codex"), "Claude, Codex"); ++ fn invalid_or_unknown_timestamp_uses_placeholder() { ++ assert_eq!(format_timestamp(0, false), "—"); ++ assert_eq!(format_timestamp(i64::MAX, false), "—"); + } + + #[test] +--- a/crates/tokscale-cli/src/tui/ui/footer.rs ++++ b/crates/tokscale-cli/src/tui/ui/footer.rs +@@ -138,7 +138,7 @@ + + fn sort_label(app: &App, field: SortField) -> &'static str { + match (app.current_tab, field) { +- (Tab::Issues, SortField::Date) => "Sessions", ++ (Tab::Issues, SortField::Date) => "Last active", + (_, SortField::Date) => "Date", + (_, SortField::Cost) => "Cost", + (_, SortField::Tokens) => "Tokens", +@@ -190,15 +190,12 @@ + ), + Tab::Daily => format!(" ({} days)", app.data.daily.len()), + Tab::Hourly => format!(" ({} hours)", app.data.hourly.len()), +- Tab::Issues => { +- let links = app +- .data +- .models +- .iter() +- .map(|model| u64::from(model.session_count)) +- .sum::(); +- format!(" ({links} model-session links)") +- } ++ Tab::Issues if app.is_session_detail_active() => { ++ format!(" ({} sessions)", app.get_sorted_sessions().len()) ++ } ++ Tab::Issues => format!( ++ " ({} sources · {} sessions)", ++ app.get_sorted_session_sources().len(), ++ app.data.sessions.len() ++ ), + Tab::Stats | Tab::Usage => String::new(), + } + } +@@ -212,15 +209,27 @@ + let is_very_narrow = app.is_very_narrow(); + + if app.current_tab == Tab::Issues { ++ let action = if app.is_session_detail_active() { ++ if is_very_narrow { ++ "esc" ++ } else { ++ "[esc:back]" ++ } ++ } else if is_very_narrow { ++ "↵" ++ } else { ++ "[enter:sessions]" ++ }; + let text = if is_very_narrow { +- "↑↓·d/t/c·s·g·r·←→·q".to_string() ++ format!("↑↓·d/t/c·{action}·s·r·←→·q") + } else { + format!( +- "↑↓ scroll • [d/t/c:sort coverage] • [s:sources] • [g:{}] • [r:refresh local] • ←→/tab view • e • q", +- app.group_by.borrow() ++ "↑↓ scroll • [d/t/c:sort] • {action} • [s:sources] • [r:refresh local] • ←→/tab view • e • q" + ) + }; + return Line::from(Span::styled(text, Style::default().fg(app.theme.muted))); +@@ -605,7 +614,7 @@ + assert_eq!(current_count_label(&make_app_on(Tab::Hourly)), " (0 hours)"); + assert_eq!( + current_count_label(&make_app_on(Tab::Issues)), +- " (0 model-session links)" ++ " (0 sources · 0 sessions)" + ); + assert_eq!(current_count_label(&make_app_on(Tab::Stats)), ""); + } +@@ -614,7 +623,7 @@ + fn sessions_tab_renames_the_date_sort_dimension() { + let app = make_app_on(Tab::Issues); + +- assert_eq!(sort_label(&app, SortField::Date), "Sessions"); ++ assert_eq!(sort_label(&app, SortField::Date), "Last active"); + assert_eq!(sort_label(&app, SortField::Cost), "Cost"); + } diff --git a/.github/workflows/finalize-issue-153-v3.yml b/.github/workflows/finalize-issue-153-v3.yml new file mode 100644 index 000000000..4d4d3d394 --- /dev/null +++ b/.github/workflows/finalize-issue-153-v3.yml @@ -0,0 +1,195 @@ +name: Finalize Issue 153 V3 + +on: + push: + branches: + - codex/issue-153-session-navigation + +permissions: + contents: write + +concurrency: + group: finalize-issue-153-v3 + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + FORMAL_BRANCH: codex/issue-153-tui-information-architecture + BASE_BRANCH: personal/local-clients + +jobs: + finalize: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + ref: codex/issue-153-session-navigation + fetch-depth: 0 + + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Materialize source-first Sessions implementation + run: | + if ! grep -q 'pub struct SessionUsage' crates/tokscale-core/src/usage_views.rs; then + for patch in .github/session-patches/*.patch; do + git apply --check "$patch" + git apply "$patch" + done + fi + + - name: Apply focused cleanup + run: | + python3 <<'PY' + from pathlib import Path + import re + + def replace_if_present(path: str, old: str, new: str) -> None: + file = Path(path) + text = file.read_text() + if old in text: + file.write_text(text.replace(old, new, 1)) + + replace_if_present( + "crates/tokscale-cli/src/tui/ui/issues.rs", + "Constraint::Length(health_height.min(area.height))", + "Constraint::Length(health_height)", + ) + replace_if_present( + "crates/tokscale-cli/src/tui/data/mod.rs", + "aggregate_by_period, aggregate_by_weekday, build_period_usage, find_peak_hour,", + "aggregate_by_period, build_period_usage, find_peak_hour,", + ) + replace_if_present( + "crates/tokscale-cli/src/tui/ui/hourly_profile.rs", + "#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]", + "#[cfg(test)]\nmod tests {\n #[test]", + ) + + app = Path("crates/tokscale-cli/src/tui/app.rs") + text = app.read_text() + tuple_pattern = re.compile( + r"let mut sources:\s*BTreeMap<\s*String,\s*" + r"\(BTreeSet, usize, u32, u32, u64, f64, i64\),\s*>\s*" + r"=\s*BTreeMap::new\(\);" + ) + replacement = ( + "type SessionSourceAggregate = " + "(BTreeSet, usize, u32, u32, u64, f64, i64);\n" + " let mut sources: BTreeMap = " + "BTreeMap::new();" + ) + text = tuple_pattern.sub(replacement, text, count=1) + obsolete_test = re.compile( + r"\n #\[test\]\n" + r" fn issues_tab_key_scrolls_text_viewport_without_table_selection\(\) \{.*?" + r"\n \}\n", + flags=re.DOTALL, + ) + text = obsolete_test.sub("\n", text, count=1) + app.write_text(text) + + cache = Path("crates/tokscale-cli/src/tui/cache.rs") + text = cache.read_text() + + def matching_brace(source: str, opening: int) -> int: + depth = 0 + in_string = False + escaped = False + for index in range(opening, len(source)): + char = source[index] + if in_string: + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == '"': + in_string = False + continue + if char == '"': + in_string = True + elif char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return index + raise SystemExit("unterminated UsageData initializer") + + insertions = [] + for match in re.finditer(r"\bUsageData\s*\{", text): + before = text[max(0, match.start() - 100):match.start()] + if re.search(r"(?:impl|struct|enum|trait|fn)[^\n{]{0,100}$", before): + continue + opening = text.find("{", match.start()) + closing = matching_brace(text, opening) + block = text[match.start():closing + 1] + if "sessions:" in block or "..Default" in block: + continue + agent_line = re.search(r"(?m)^(\s*)agents:\s*Vec::new\(\),\s*$", block) + if agent_line is None: + continue + absolute_end = match.start() + agent_line.end() + insertions.append((absolute_end, agent_line.group(1))) + + if len(insertions) > 1: + raise SystemExit(f"ambiguous missing sessions fields: {len(insertions)}") + for position, indent in reversed(insertions): + text = text[:position] + f"\n{indent}sessions: Vec::new()," + text[position:] + cache.write_text(text) + + themes = Path("crates/tokscale-cli/src/tui/themes.rs") + if themes.exists(): + text = themes.read_text() + if text.count("metric_total_style(") == 1: + text = re.sub( + r"\n pub\(crate\) fn metric_total_style\(&self\) -> Style \{.*?\n \}\n", + "\n", + text, + count=1, + flags=re.DOTALL, + ) + themes.write_text(text) + PY + + - name: Remove temporary validation scaffolding + run: | + rm -rf .github/session-patches + rm -f \ + .github/session-navigation-v2.patch \ + .github/session-navigation-patch-source.yml \ + .github/session-navigation-trigger \ + .github/workflows/prepare-session-navigation.yml \ + .github/workflows/session-navigation-diagnostics.yml \ + .github/workflows/session-navigation-validation.yml \ + .github/workflows/session-navigation-validation-v2.yml \ + .github/workflows/session-navigation-materialize.yml \ + .github/workflows/finalize-issue-153.yml \ + .github/workflows/finalize-issue-153-v3.yml + + - name: Format + run: cargo fmt --all + + - name: Apply safe Clippy suggestions + run: | + cargo clippy --fix --workspace --all-targets --all-features \ + --allow-dirty --allow-staged -- -W warnings || true + cargo fmt --all + + - name: Strict Clippy + run: cargo clippy --workspace --all-targets --all-features -- -D warnings + + - name: Full test suite + run: cargo test --workspace --all-targets --all-features + + - name: Publish one semantic commit to the formal PR branch + run: | + git config user.name "OpenAI Codex" + git config user.email "codex@openai.com" + git fetch origin "$BASE_BRANCH" "$FORMAL_BRANCH" + git reset --soft "origin/$BASE_BRANCH" + git add -A + git commit -m "refactor(tui): reorganize overview stats and sessions" + git push --force-with-lease origin "HEAD:$FORMAL_BRANCH" diff --git a/.github/workflows/finalize-issue-153.yml b/.github/workflows/finalize-issue-153.yml new file mode 100644 index 000000000..76fb2522e --- /dev/null +++ b/.github/workflows/finalize-issue-153.yml @@ -0,0 +1,186 @@ +name: Finalize Issue 153 + +on: + push: + branches: + - codex/issue-153-session-navigation + +permissions: + contents: write + +concurrency: + group: finalize-issue-153 + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + FORMAL_BRANCH: codex/issue-153-tui-information-architecture + BASE_BRANCH: personal/local-clients + +jobs: + finalize: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + ref: codex/issue-153-session-navigation + fetch-depth: 0 + + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Materialize Sessions implementation when needed + run: | + if [ -d .github/session-patches ]; then + for patch in .github/session-patches/*.patch; do + git apply --check "$patch" + git apply "$patch" + done + fi + + - name: Apply focused cleanup + run: | + python3 <<'PY' + from pathlib import Path + import re + + issues = Path("crates/tokscale-cli/src/tui/ui/issues.rs") + text = issues.read_text().replace( + "Constraint::Length(health_height.min(area.height))", + "Constraint::Length(health_height)", + ) + issues.write_text(text) + + app = Path("crates/tokscale-cli/src/tui/app.rs") + text = app.read_text() + tuple_pattern = re.compile( + r"let mut sources:\s*BTreeMap<\s*String,\s*" + r"\(BTreeSet, usize, u32, u32, u64, f64, i64\),\s*>\s*" + r"=\s*BTreeMap::new\(\);" + ) + replacement = ( + "type SessionSourceAggregate = " + "(BTreeSet, usize, u32, u32, u64, f64, i64);\n" + " let mut sources: BTreeMap = " + "BTreeMap::new();" + ) + text, replacements = tuple_pattern.subn(replacement, text, count=1) + if replacements == 0 and not any( + marker in text + for marker in ("SessionSourceAggregate", "SessionSourceAccumulator") + ): + raise SystemExit("could not locate the session source accumulator") + app.write_text(text) + + cache = Path("crates/tokscale-cli/src/tui/cache.rs") + text = cache.read_text() + + def matching_brace(source: str, opening: int) -> int: + depth = 0 + in_string = False + escaped = False + for index in range(opening, len(source)): + char = source[index] + if in_string: + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == '"': + in_string = False + continue + if char == '"': + in_string = True + elif char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return index + raise SystemExit("unterminated UsageData initializer") + + insertions = [] + for match in re.finditer(r"\bUsageData\s*\{", text): + before = text[max(0, match.start() - 100):match.start()] + if re.search(r"(?:impl|struct|enum|trait|fn)[^\n{]{0,100}$", before): + continue + opening = text.find("{", match.start()) + closing = matching_brace(text, opening) + block = text[match.start():closing + 1] + if "sessions:" in block or "..Default" in block: + continue + agent_line = re.search(r"(?m)^(\s*)agents:\s*Vec::new\(\),\s*$", block) + if agent_line is None: + raise SystemExit("UsageData initializer without sessions has no simple agents field") + absolute_end = match.start() + agent_line.end() + insertions.append((absolute_end, agent_line.group(1))) + + if len(insertions) > 1: + raise SystemExit(f"expected at most one missing sessions field, found {len(insertions)}") + for position, indent in reversed(insertions): + text = text[:position] + f"\n{indent}sessions: Vec::new()," + text[position:] + cache.write_text(text) + PY + + - name: Remove validation scaffolding + run: | + rm -rf .github/session-patches + rm -f \ + .github/workflows/session-navigation-validation.yml \ + .github/workflows/session-navigation-validation-v2.yml \ + .github/workflows/session-navigation-materialize.yml \ + .github/workflows/finalize-issue-153.yml + + - name: Format + run: cargo fmt --all + + - name: Clippy + id: clippy + continue-on-error: true + run: | + set -o pipefail + cargo clippy --workspace --all-targets --all-features -- -D warnings 2>&1 | + tee issue-153-clippy.log + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: issue-153-clippy-log + path: issue-153-clippy.log + if-no-files-found: warn + + - name: Stop after Clippy failure + if: steps.clippy.outcome == 'failure' + run: exit 1 + + - name: Test + id: test + continue-on-error: true + run: | + set -o pipefail + cargo test --workspace --all-targets --all-features 2>&1 | + tee issue-153-test.log + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: issue-153-test-log + path: issue-153-test.log + if-no-files-found: warn + + - name: Stop after test failure + if: steps.test.outcome == 'failure' + run: exit 1 + + - name: Publish one clean semantic commit to PR branch + run: | + git config user.name "OpenAI Codex" + git config user.email "codex@openai.com" + git fetch origin "$BASE_BRANCH" "$FORMAL_BRANCH" + git merge-base --is-ancestor "origin/$BASE_BRANCH" HEAD + git reset --soft "origin/$BASE_BRANCH" + git add -A + git commit -m "refactor(tui): reorganize overview stats and sessions" + git push --force-with-lease origin "HEAD:$FORMAL_BRANCH" diff --git a/.github/workflows/session-navigation-materialize.yml b/.github/workflows/session-navigation-materialize.yml new file mode 100644 index 000000000..05d327d0c --- /dev/null +++ b/.github/workflows/session-navigation-materialize.yml @@ -0,0 +1,130 @@ +name: Materialize Session Navigation + +on: + push: + branches: + - codex/issue-153-session-navigation + +permissions: + contents: write + +concurrency: + group: session-navigation-materialize + cancel-in-progress: true + +jobs: + materialize: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + ref: codex/issue-153-session-navigation + fetch-depth: 0 + + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + + - name: Apply source-first Sessions implementation + run: | + for patch in .github/session-patches/*.patch; do + git apply --check "$patch" + git apply "$patch" + done + + - name: Apply focused cleanup + run: | + python3 <<'PY' + from pathlib import Path + import re + + issues = Path("crates/tokscale-cli/src/tui/ui/issues.rs") + text = issues.read_text().replace( + "Constraint::Length(health_height.min(area.height))", + "Constraint::Length(health_height)", + 1, + ) + issues.write_text(text) + + app = Path("crates/tokscale-cli/src/tui/app.rs") + text = app.read_text() + tuple_pattern = re.compile( + r"let mut sources:\s*BTreeMap<\s*String,\s*" + r"\(BTreeSet, usize, u32, u32, u64, f64, i64\),\s*>\s*" + r"=\s*BTreeMap::new\(\);" + ) + replacement = ( + "type SessionSourceAggregate = " + "(BTreeSet, usize, u32, u32, u64, f64, i64);\n" + " let mut sources: BTreeMap = " + "BTreeMap::new();" + ) + text, replacements = tuple_pattern.subn(replacement, text, count=1) + if replacements == 0 and "SessionSourceAccumulator" not in text: + raise SystemExit("could not locate the session source accumulator") + app.write_text(text) + + cache = Path("crates/tokscale-cli/src/tui/cache.rs") + text = cache.read_text() + + def matching_brace(source: str, opening: int) -> int: + depth = 0 + in_string = False + escaped = False + for index in range(opening, len(source)): + char = source[index] + if in_string: + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == '"': + in_string = False + continue + if char == '"': + in_string = True + elif char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return index + raise SystemExit("unterminated UsageData initializer") + + insertions = [] + for match in re.finditer(r"\bUsageData\s*\{", text): + before = text[max(0, match.start() - 100):match.start()] + if re.search(r"(?:impl|struct|enum|trait|fn)[^\n{]{0,100}$", before): + continue + opening = text.find("{", match.start()) + closing = matching_brace(text, opening) + block = text[match.start():closing + 1] + if "sessions:" in block or "..Default" in block: + continue + agent_line = re.search(r"(?m)^(\s*)agents:\s*Vec::new\(\),\s*$", block) + if agent_line is None: + raise SystemExit("UsageData initializer without sessions has no simple agents field") + absolute_end = match.start() + agent_line.end() + insertions.append((absolute_end, agent_line.group(1))) + + if len(insertions) != 1: + raise SystemExit(f"expected one missing sessions field, found {len(insertions)}") + for position, indent in reversed(insertions): + text = text[:position] + f"\n{indent}sessions: Vec::new()," + text[position:] + cache.write_text(text) + PY + + - name: Format and commit + run: | + cargo fmt --all + git rm -rf \ + .github/session-patches \ + .github/workflows/session-navigation-validation.yml \ + .github/workflows/session-navigation-validation-v2.yml \ + .github/workflows/session-navigation-materialize.yml + git config user.name "OpenAI Codex" + git config user.email "codex@openai.com" + git add -A + git commit -m "fix(tui): add source-first session navigation" + git push origin HEAD:codex/issue-153-session-navigation diff --git a/.github/workflows/session-navigation-validation-v2.yml b/.github/workflows/session-navigation-validation-v2.yml new file mode 100644 index 000000000..d4ddb9752 --- /dev/null +++ b/.github/workflows/session-navigation-validation-v2.yml @@ -0,0 +1,173 @@ +name: Session Navigation Validation V2 + +on: + push: + branches: + - codex/issue-153-session-navigation + +permissions: + contents: write + +concurrency: + group: session-navigation-validation-v2 + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + validate: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + ref: codex/issue-153-session-navigation + fetch-depth: 0 + + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Apply source-first Sessions implementation + run: | + for patch in .github/session-patches/*.patch; do + git apply --check "$patch" + git apply "$patch" + done + git rm -rf \ + .github/session-patches \ + .github/workflows/session-navigation-validation.yml \ + .github/workflows/session-navigation-validation-v2.yml + + - name: Apply focused lint cleanup + run: | + python3 <<'PY' + from pathlib import Path + import re + + issues = Path("crates/tokscale-cli/src/tui/ui/issues.rs") + text = issues.read_text() + text = text.replace( + "Constraint::Length(health_height.min(area.height))", + "Constraint::Length(health_height)", + 1, + ) + issues.write_text(text) + + app = Path("crates/tokscale-cli/src/tui/app.rs") + text = app.read_text() + tuple_pattern = re.compile( + r"let mut sources:\s*BTreeMap<\s*String,\s*" + r"\(BTreeSet, usize, u32, u32, u64, f64, i64\),\s*>\s*" + r"=\s*BTreeMap::new\(\);" + ) + replacement = ( + "type SessionSourceAggregate = " + "(BTreeSet, usize, u32, u32, u64, f64, i64);\n" + " let mut sources: BTreeMap = " + "BTreeMap::new();" + ) + text, replacements = tuple_pattern.subn(replacement, text, count=1) + if replacements == 0 and "SessionSourceAccumulator" not in text: + raise SystemExit("could not locate the session source accumulator") + app.write_text(text) + + cache = Path("crates/tokscale-cli/src/tui/cache.rs") + text = cache.read_text() + + def matching_brace(source: str, opening: int) -> int: + depth = 0 + in_string = False + escaped = False + for index in range(opening, len(source)): + char = source[index] + if in_string: + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == '"': + in_string = False + continue + if char == '"': + in_string = True + elif char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return index + raise SystemExit("unterminated UsageData initializer") + + insertions = [] + for match in re.finditer(r"\bUsageData\s*\{", text): + before = text[max(0, match.start() - 100):match.start()] + if re.search(r"(?:impl|struct|enum|trait|fn)[^\n{]{0,100}$", before): + continue + opening = text.find("{", match.start()) + closing = matching_brace(text, opening) + block = text[match.start():closing + 1] + if "sessions:" in block or "..Default" in block: + continue + agent_line = re.search(r"(?m)^(\s*)agents:\s*Vec::new\(\),\s*$", block) + if agent_line is None: + raise SystemExit("UsageData initializer without sessions has no simple agents field") + absolute_end = match.start() + agent_line.end() + insertions.append((absolute_end, agent_line.group(1))) + + if len(insertions) != 1: + raise SystemExit(f"expected one missing sessions field, found {len(insertions)}") + for position, indent in reversed(insertions): + text = text[:position] + f"\n{indent}sessions: Vec::new()," + text[position:] + cache.write_text(text) + PY + + - name: Format + run: cargo fmt --all + + - name: Clippy + id: clippy + continue-on-error: true + run: | + set -o pipefail + cargo clippy --workspace --all-targets --all-features -- -D warnings 2>&1 | + tee session-navigation-clippy-v2.log + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: session-navigation-clippy-v2-log + path: session-navigation-clippy-v2.log + if-no-files-found: warn + + - name: Stop after Clippy failure + if: steps.clippy.outcome == 'failure' + run: exit 1 + + - name: Test + id: test + continue-on-error: true + run: | + set -o pipefail + cargo test --workspace --all-targets --all-features 2>&1 | + tee session-navigation-test-v2.log + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: session-navigation-test-v2-log + path: session-navigation-test-v2.log + if-no-files-found: warn + + - name: Stop after test failure + if: steps.test.outcome == 'failure' + run: exit 1 + + - name: Commit validated implementation + run: | + git config user.name "OpenAI Codex" + git config user.email "codex@openai.com" + git add -A + git commit -m "fix(tui): add source-first session navigation" + git push origin HEAD:codex/issue-153-session-navigation diff --git a/.github/workflows/session-navigation-validation.yml b/.github/workflows/session-navigation-validation.yml new file mode 100644 index 000000000..fb234a511 --- /dev/null +++ b/.github/workflows/session-navigation-validation.yml @@ -0,0 +1,132 @@ +name: Session Navigation Validation + +on: + push: + branches: + - codex/issue-153-session-navigation + pull_request: + branches: + - personal/local-clients + +permissions: + contents: write + +concurrency: + group: session-navigation-validation + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + validate: + if: >- + github.actor != 'github-actions[bot]' && + (github.event_name != 'pull_request' || + github.head_ref == 'codex/issue-153-session-navigation') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + ref: codex/issue-153-session-navigation + fetch-depth: 0 + + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Normalize and check source-first Sessions patch + id: patch + shell: bash + run: | + set +e + ( + python3 <<'PY' + from pathlib import Path + import re + + source = Path('.github/session-navigation-v2.patch').read_text().splitlines(keepends=True) + output: list[str] = [] + index = 0 + header = re.compile(r'^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*?)(\r?\n)?$') + + while index < len(source): + match = header.match(source[index]) + if match is None: + output.append(source[index]) + index += 1 + continue + + end = index + 1 + while end < len(source): + line = source[end] + if line.startswith('@@ ') or line.startswith('--- '): + break + end += 1 + + body = source[index + 1:end] + old_count = sum( + 1 for line in body if line.startswith(' ') or line.startswith('-') + ) + new_count = sum( + 1 for line in body if line.startswith(' ') or line.startswith('+') + ) + newline = match.group(4) or '\n' + output.append( + f'@@ -{match.group(1)},{old_count} +{match.group(2)},{new_count} @@' + f'{match.group(3)}{newline}' + ) + output.extend(body) + index = end + + Path('/tmp/session-navigation-v2.patch').write_text(''.join(output)) + PY + git apply --check /tmp/session-navigation-v2.patch + ) > /tmp/session-normalization-diagnostics.log 2>&1 + status=$? + cat /tmp/session-normalization-diagnostics.log + echo "status=${status}" >> "$GITHUB_OUTPUT" + exit 0 + + - name: Upload normalized patch diagnostics + if: steps.patch.outputs.status != '0' + uses: actions/upload-artifact@v4 + with: + name: normalized-session-patch-diagnostics + path: | + /tmp/session-navigation-v2.patch + /tmp/session-normalization-diagnostics.log + if-no-files-found: error + + - name: Stop after patch failure + if: steps.patch.outputs.status != '0' + run: exit 1 + + - name: Apply source-first Sessions implementation + if: steps.patch.outputs.status == '0' + run: | + git apply /tmp/session-navigation-v2.patch + git rm -f \ + .github/session-navigation-v2.patch \ + .github/workflows/session-navigation-validation.yml + + - name: Format + if: steps.patch.outputs.status == '0' + run: cargo fmt --all + + - name: Clippy + if: steps.patch.outputs.status == '0' + run: cargo clippy --workspace --all-targets --all-features -- -D warnings + + - name: Test + if: steps.patch.outputs.status == '0' + run: cargo test --workspace --all-features + + - name: Commit validated implementation + if: steps.patch.outputs.status == '0' + run: | + git config user.name "OpenAI Codex" + git config user.email "codex@openai.com" + git add -A + git commit -m "fix(tui): add source-first session navigation" + git push origin HEAD:codex/issue-153-session-navigation diff --git a/crates/tokscale-cli/src/tui/ui/daily_profile.rs b/crates/tokscale-cli/src/tui/ui/daily_profile.rs new file mode 100644 index 000000000..bbef35a72 --- /dev/null +++ b/crates/tokscale-cli/src/tui/ui/daily_profile.rs @@ -0,0 +1,118 @@ +use chrono::Datelike; +use ratatui::prelude::*; +use ratatui::widgets::{Block, Borders, Paragraph}; + +use crate::tui::app::App; + +pub(crate) const PANEL_HEIGHT: u16 = 10; +pub(crate) const MIN_COMBINED_HEIGHT: u16 = 18; + +const WEEKDAYS: [&str; 7] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + +pub fn render(frame: &mut Frame, app: &App, area: Rect) { + let mut totals = [0u64; 7]; + for day in &app.data.daily { + let index = day.date.weekday().num_days_from_monday() as usize; + totals[index] = totals[index] + .checked_add(day.tokens.total()) + .expect("daily profile token total exceeds u64::MAX"); + } + + let best_index = totals + .iter() + .enumerate() + .max_by(|(left_index, left), (right_index, right)| { + left.cmp(right).then_with(|| right_index.cmp(left_index)) + }) + .map(|(index, _)| index) + .unwrap_or(0); + let total_tokens = totals + .iter() + .copied() + .try_fold(0u64, u64::checked_add) + .expect("daily profile total exceeds u64::MAX"); + let max_tokens = totals.iter().copied().max().unwrap_or(0); + + let mut block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(app.theme.border)) + .title(Span::styled( + " Daily Profile ", + Style::default() + .fg(app.theme.accent) + .add_modifier(Modifier::BOLD), + )) + .style(Style::default().bg(app.theme.background)); + if total_tokens > 0 { + block = block.title_top( + Line::from(Span::styled( + format!(" Most productive: {} ", WEEKDAYS[best_index]), + Style::default().fg(Color::Yellow), + )) + .right_aligned(), + ); + } + + let inner = block.inner(area); + frame.render_widget(block, area); + if inner.is_empty() { + return; + } + if total_tokens == 0 { + frame.render_widget( + Paragraph::new("No daily usage data available") + .style(Style::default().fg(app.theme.muted)) + .alignment(Alignment::Center), + inner, + ); + return; + } + + let bar_width = (inner.width as usize).saturating_sub(22).clamp(1, 28); + let lines = WEEKDAYS + .iter() + .enumerate() + .map(|(index, label)| { + let value = totals[index]; + let percentage = value as f64 / total_tokens as f64 * 100.0; + let filled = if max_tokens > 0 { + (value as f64 / max_tokens as f64 * bar_width as f64).round() as usize + } else { + 0 + } + .min(bar_width); + let label_style = if index == best_index { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(app.theme.foreground) + }; + Line::from(vec![ + Span::styled(format!(" {label:<3} "), label_style), + Span::styled("█".repeat(filled), Style::default().fg(Color::Green)), + Span::styled( + "░".repeat(bar_width.saturating_sub(filled)), + app.theme.subtle_text_style(), + ), + Span::styled( + format!(" {:>5.1}%", percentage), + Style::default().fg(app.theme.muted), + ), + ]) + }) + .take(inner.height as usize) + .collect::>(); + + frame.render_widget(Paragraph::new(lines), inner); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn profile_and_table_have_a_deliberate_minimum_height() { + assert!(MIN_COMBINED_HEIGHT > PANEL_HEIGHT); + } +} diff --git a/crates/tokscale-cli/src/tui/ui/dialog/group_by_picker.rs b/crates/tokscale-cli/src/tui/ui/dialog/group_by_picker.rs index 0a1215260..d63f333d1 100644 --- a/crates/tokscale-cli/src/tui/ui/dialog/group_by_picker.rs +++ b/crates/tokscale-cli/src/tui/ui/dialog/group_by_picker.rs @@ -45,32 +45,22 @@ impl GroupByPickerDialog { GroupByOption { value: GroupBy::Model, label: "Model", - description: "One row per model (merge clients & providers)", + description: "One row per model; merge harnesses and providers", }, GroupByOption { value: GroupBy::ClientModel, - label: "Client + Model", - description: "One row per client-model pair (default)", + label: "Harness + Model", + description: "One row per harness-model pair (default)", }, GroupByOption { value: GroupBy::ClientProviderModel, - label: "Client + Provider + Model", - description: "Most granular — no merging", + label: "Harness + Provider + Model", + description: "Keep provider identity; no model merging", }, GroupByOption { value: GroupBy::WorkspaceModel, label: "Workspace + Model", - description: "Group local usage by workspace key, then model", - }, - GroupByOption { - value: GroupBy::Session, - label: "Session + Model", - description: "One row per session_id and model (attribute cost per session)", - }, - GroupByOption { - value: GroupBy::ClientSession, - label: "Client + Session + Model", - description: "One row per client, session_id, and model", + description: "Group local usage by workspace, then model", }, ]; @@ -162,12 +152,9 @@ fn option_index_for_row( impl DialogContent for GroupByPickerDialog { fn desired_size(&self, viewport: Rect) -> (u16, u16) { - // 6 options render as 2 lines each (label + description) = 12 rows, - // plus header (1) + divider (1) + hint (1) + borders (2). Cap at 18 - // so every option stays visible without scrolling on a typical - // terminal; matches source_picker's sizing. - let width = 52u16.min(viewport.width.saturating_sub(4)); - let height = 18u16.min(viewport.height.saturating_sub(4)); + // Four options use two rows each, plus header, divider, hint, and borders. + let width = 54u16.min(viewport.width.saturating_sub(4)); + let height = 14u16.min(viewport.height.saturating_sub(4)); (width, height) } @@ -323,15 +310,15 @@ mod tests { let mut dialog = make_dialog(GroupBy::ClientModel); dialog.cursor = 0; - let rendered = render_symbols(&dialog, Rect::new(0, 0, 52, 18)); + let rendered = render_symbols(&dialog, Rect::new(0, 0, 54, 14)); - assert!(rendered.contains("(●) Client + Model current")); + assert!(rendered.contains("(●) Harness + Model current")); } #[test] fn group_by_picker_mouse_hitbox_selects_label_row() { let mut dialog = make_dialog(GroupBy::ClientModel); - let area = Rect::new(0, 0, 52, 18); + let area = Rect::new(0, 0, 54, 14); let list = group_by_picker_areas(area).list; let result = dialog.handle_mouse(click(list.x, list.y + 6), area); @@ -344,7 +331,7 @@ mod tests { #[test] fn group_by_picker_mouse_hitbox_selects_description_row() { let mut dialog = make_dialog(GroupBy::ClientModel); - let area = Rect::new(0, 0, 52, 18); + let area = Rect::new(0, 0, 54, 14); let list = group_by_picker_areas(area).list; let result = dialog.handle_mouse(click(list.x, list.y + 7), area); @@ -357,10 +344,10 @@ mod tests { #[test] fn group_by_picker_mouse_outside_rows_does_not_select() { let mut dialog = make_dialog(GroupBy::ClientModel); - let area = Rect::new(0, 0, 52, 18); + let area = Rect::new(0, 0, 54, 14); let list = group_by_picker_areas(area).list; - let result = dialog.handle_mouse(click(list.x, list.y + 12), area); + let result = dialog.handle_mouse(click(list.x, list.y + 8), area); assert!(matches!( result, @@ -369,4 +356,22 @@ mod tests { assert_eq!(*dialog.selected.borrow(), GroupBy::ClientModel); assert!(!*dialog.needs_reload.borrow()); } + + #[test] + fn group_by_picker_exposes_only_report_dimensions() { + let dialog = make_dialog(GroupBy::ClientModel); + + assert_eq!(dialog.options.len(), 4); + assert!(dialog + .options + .iter() + .all(|option| !matches!(option.value, GroupBy::Session | GroupBy::ClientSession))); + } + + #[test] + fn legacy_session_selection_falls_back_to_default_cursor() { + let dialog = make_dialog(GroupBy::Session); + + assert_eq!(dialog.cursor, 1); + } } diff --git a/crates/tokscale-cli/src/tui/ui/footer.rs b/crates/tokscale-cli/src/tui/ui/footer.rs index 3cbff97d4..056328104 100644 --- a/crates/tokscale-cli/src/tui/ui/footer.rs +++ b/crates/tokscale-cli/src/tui/ui/footer.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeSet; + use ratatui::prelude::*; use ratatui::widgets::{Block, Borders, Paragraph}; @@ -54,20 +56,17 @@ fn render_main_row(frame: &mut Frame, app: &mut App, area: Rect) { .split(area); // Left side: sort buttons - if !is_very_narrow { + if !is_very_narrow && sort_controls_visible(app) { let mut spans: Vec = Vec::new(); let mut x_offset = chunks[0].x; spans.push(Span::styled("Sort: ", Style::default().fg(app.theme.muted))); x_offset += 6; - let sort_buttons = [ - (SortField::Date, "Date"), - (SortField::Cost, "Cost"), - (SortField::Tokens, "Tokens"), - ]; + let sort_buttons = [SortField::Date, SortField::Cost, SortField::Tokens]; - for (field, label) in sort_buttons { + for field in sort_buttons { + let label = sort_label(app, field); let is_active = app.sort_field == field; let style = if is_active { Style::default() @@ -96,22 +95,6 @@ fn render_main_row(frame: &mut Frame, app: &mut App, area: Rect) { // Right side: scroll info | tokens | cost let mut right_spans: Vec = Vec::new(); - // Scroll position indicator for Overview tab - if app.current_tab == Tab::Overview { - let total_models = app.data.models.len(); - if total_models > app.max_visible_items && app.max_visible_items > 0 { - let start = app.scroll_offset + 1; - let end = (app.scroll_offset + app.max_visible_items).min(total_models); - if !is_very_narrow { - right_spans.push(Span::styled( - format!("↓ {}-{} of {} ", start, end, total_models), - Style::default().fg(app.theme.muted), - )); - right_spans.push(Span::styled("| ", Style::default().fg(app.theme.muted))); - } - } - } - // Total tokens let total_tokens = app.data.total_tokens; right_spans.push(Span::styled( @@ -149,9 +132,44 @@ fn render_main_row(frame: &mut Frame, app: &mut App, area: Rect) { frame.render_widget(right_para, chunks[1]); } +fn sort_controls_visible(app: &App) -> bool { + !matches!(app.current_tab, Tab::Overview | Tab::Stats | Tab::Usage) +} + +fn sort_label(app: &App, field: SortField) -> &'static str { + match (app.current_tab, field) { + (Tab::Issues, SortField::Date) => "Sessions", + (_, SortField::Date) => "Date", + (_, SortField::Cost) => "Cost", + (_, SortField::Tokens) => "Tokens", + } +} + fn current_count_label(app: &App) -> String { match app.current_tab { - Tab::Overview | Tab::Models => format!(" ({} models)", app.data.models.len()), + Tab::Overview => { + let mut models = BTreeSet::new(); + let mut harnesses = BTreeSet::new(); + for day in &app.data.daily { + for (harness, source) in &day.source_breakdown { + harnesses.insert(harness.as_str()); + for (key, model) in &source.models { + models.insert(if model.color_key.is_empty() { + key.as_str() + } else { + model.color_key.as_str() + }); + } + } + } + format!( + " ({} models · {} harnesses · {} days)", + models.len(), + harnesses.len(), + app.data.daily.len() + ) + } + Tab::Models => format!(" ({} models)", app.data.models.len()), Tab::Agents => format!(" ({} agents)", app.data.agents.len()), Tab::Daily if app.is_daily_detail_active() => { format!(" ({} models)", app.get_sorted_daily_detail_rows().len()) @@ -172,7 +190,15 @@ fn current_count_label(app: &App) -> String { ), Tab::Daily => format!(" ({} days)", app.data.daily.len()), Tab::Hourly => format!(" ({} hours)", app.data.hourly.len()), - Tab::Issues => String::new(), + Tab::Issues => { + let links = app + .data + .models + .iter() + .map(|model| u64::from(model.session_count)) + .sum::(); + format!(" ({links} model-session links)") + } Tab::Stats | Tab::Usage => String::new(), } } @@ -187,9 +213,12 @@ fn help_row_line(app: &App) -> Line<'static> { if app.current_tab == Tab::Issues { let text = if is_very_narrow { - "↑↓·←→·r·e·q" + "↑↓·d/t/c·s·g·r·←→·q".to_string() } else { - "↑↓ scroll • ←→/tab view • [r:refresh local] • e • q" + format!( + "↑↓ scroll • [d/t/c:sort coverage] • [s:sources] • [g:{}] • [r:refresh local] • ←→/tab view • e • q", + app.group_by.borrow() + ) }; return Line::from(Span::styled(text, Style::default().fg(app.theme.muted))); } @@ -546,6 +575,10 @@ mod tests { #[test] fn test_current_count_label_matches_active_tab() { + assert_eq!( + current_count_label(&make_app_on(Tab::Overview)), + " (0 models · 0 harnesses · 0 days)" + ); assert_eq!( current_count_label(&make_app_on(Tab::Models)), " (0 models)" @@ -561,9 +594,21 @@ mod tests { assert_eq!(current_count_label(&make_app_on(Tab::Weekly)), " (0 weeks)"); assert_eq!(current_count_label(&make_app_on(Tab::Daily)), " (0 days)"); assert_eq!(current_count_label(&make_app_on(Tab::Hourly)), " (0 hours)"); + assert_eq!( + current_count_label(&make_app_on(Tab::Issues)), + " (0 model-session links)" + ); assert_eq!(current_count_label(&make_app_on(Tab::Stats)), ""); } + #[test] + fn sessions_tab_renames_the_date_sort_dimension() { + let app = make_app_on(Tab::Issues); + + assert_eq!(sort_label(&app, SortField::Date), "Sessions"); + assert_eq!(sort_label(&app, SortField::Cost), "Cost"); + } + #[test] fn usage_help_row_shows_subscription_and_local_refresh_keys() { let mut app = make_app_on(Tab::Overview); diff --git a/crates/tokscale-cli/src/tui/ui/header.rs b/crates/tokscale-cli/src/tui/ui/header.rs index dec97f78b..3fe4a619c 100644 --- a/crates/tokscale-cli/src/tui/ui/header.rs +++ b/crates/tokscale-cli/src/tui/ui/header.rs @@ -88,9 +88,11 @@ fn tab_divider(app: &App) -> Span<'static> { } fn tab_label(_app: &App, tab: Tab, mode: TabLabelMode) -> Cow<'static, str> { - match mode { - TabLabelMode::Full => Cow::Borrowed(tab.as_str()), - TabLabelMode::Short => Cow::Borrowed(tab.short_name()), + match (tab, mode) { + (Tab::Issues, TabLabelMode::Full) => Cow::Borrowed("Sessions"), + (Tab::Issues, TabLabelMode::Short) => Cow::Borrowed("Ses"), + (_, TabLabelMode::Full) => Cow::Borrowed(tab.as_str()), + (_, TabLabelMode::Short) => Cow::Borrowed(tab.short_name()), } } @@ -266,7 +268,7 @@ mod tests { (Rect::new(78, 5, 8, 1), Tab::Hourly), (Rect::new(89, 5, 7, 1), Tab::Stats), (Rect::new(99, 5, 8, 1), Tab::Agents), - (Rect::new(110, 5, 8, 1), Tab::Issues), + (Rect::new(110, 5, 10, 1), Tab::Issues), ] } @@ -281,7 +283,7 @@ mod tests { (Rect::new(88, 5, 8, 1), Tab::Hourly), (Rect::new(99, 5, 7, 1), Tab::Stats), (Rect::new(109, 5, 8, 1), Tab::Agents), - (Rect::new(120, 5, 8, 1), Tab::Issues), + (Rect::new(120, 5, 10, 1), Tab::Issues), ] } @@ -431,7 +433,7 @@ mod tests { assert_eq!(symbols_at(&lines, 5, 78, 8), " Hourly "); assert_eq!(symbols_at(&lines, 5, 89, 7), " Stats "); assert_eq!(symbols_at(&lines, 5, 99, 8), " Agents "); - assert_eq!(symbols_at(&lines, 5, 110, 8), " Issues "); + assert_eq!(symbols_at(&lines, 5, 110, 10), " Sessions "); assert_eq!(registered_tab_areas(&app), expected_normal_tab_areas()); } @@ -450,7 +452,7 @@ mod tests { assert_eq!(symbols_at(&lines, 3, 47, 4), " Hr "); assert_eq!(symbols_at(&lines, 3, 54, 5), " Sta "); assert_eq!(symbols_at(&lines, 3, 62, 5), " Agt "); - assert_eq!(symbols_at(&lines, 3, 70, 5), " Iss "); + assert_eq!(symbols_at(&lines, 3, 70, 5), " Ses "); assert_eq!(registered_tab_areas(&app), expected_very_narrow_tab_areas()); } @@ -537,14 +539,14 @@ mod tests { } #[test] - fn issues_label_stays_quiet_when_issues_exist() { + fn sessions_label_stays_quiet_when_issues_exist() { let mut app = make_app(140); app.data.health.complete = false; app.data.health.rejected_records = 2; app.data.health.failed_sources = 1; - assert_eq!(tab_label(&app, Tab::Issues, TabLabelMode::Full), "Issues"); - assert_eq!(tab_label(&app, Tab::Issues, TabLabelMode::Short), "Iss"); + assert_eq!(tab_label(&app, Tab::Issues, TabLabelMode::Full), "Sessions"); + assert_eq!(tab_label(&app, Tab::Issues, TabLabelMode::Short), "Ses"); let area = Rect::new(20, 4, 106, 3); let lines = render_header_symbols(&mut app, area, 140, 8); @@ -553,22 +555,22 @@ mod tests { .find(|(_, tab)| *tab == Tab::Issues) .expect("Issues tab must remain visible"); - assert_eq!(symbols_at(&lines, 5, 110, 8), " Issues "); - assert_eq!(issues_area, (Rect::new(110, 5, 8, 1), Tab::Issues)); + assert_eq!(symbols_at(&lines, 5, 110, 10), " Sessions "); + assert_eq!(issues_area, (Rect::new(110, 5, 10, 1), Tab::Issues)); } #[test] - fn issues_tab_remains_rendered_and_clickable_in_a_real_fifty_column_header() { + fn sessions_tab_remains_rendered_and_clickable_in_a_real_fifty_column_header() { let mut app = make_app(50); let lines = render_header_symbols(&mut app, Rect::new(0, 0, 50, 3), 50, 4); let (rect, tab) = registered_tab_areas(&app) .into_iter() .find(|(_, tab)| *tab == Tab::Issues) - .expect("narrow fitting must reserve space for Issues"); + .expect("narrow fitting must reserve space for Sessions"); assert_eq!(tab, Tab::Issues); assert!(rect.right() <= 49); - assert_eq!(symbols_at(&lines, rect.y, rect.x, rect.width), " Iss "); + assert_eq!(symbols_at(&lines, rect.y, rect.x, rect.width), " Ses "); app.handle_mouse_event(MouseEvent { kind: MouseEventKind::Down(MouseButton::Left), diff --git a/crates/tokscale-cli/src/tui/ui/hourly_profile.rs b/crates/tokscale-cli/src/tui/ui/hourly_profile.rs index 70dbce7f8..b443348f2 100644 --- a/crates/tokscale-cli/src/tui/ui/hourly_profile.rs +++ b/crates/tokscale-cli/src/tui/ui/hourly_profile.rs @@ -3,7 +3,7 @@ use ratatui::widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientatio use super::widgets::{format_cost, format_tokens, viewport_scrollbar_state}; use crate::tui::app::App; -use crate::tui::data::{aggregate_by_period, aggregate_by_weekday, find_peak_hour}; +use crate::tui::data::{aggregate_by_period, find_peak_hour}; pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { let block = Block::default() @@ -16,47 +16,42 @@ pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { .add_modifier(Modifier::BOLD), )) .style(Style::default().bg(app.theme.background)); - let inner = block.inner(area); frame.render_widget(block, area); if app.data.hourly.is_empty() { app.set_hourly_profile_text_viewport(inner.height as usize, 0); - let empty_msg = Paragraph::new("No hourly usage data found. Press 'r' to refresh.") - .style(Style::default().fg(app.theme.muted)) - .alignment(Alignment::Center); - frame.render_widget(empty_msg, inner); + frame.render_widget( + Paragraph::new("No hourly usage data found. Press 'r' to refresh.") + .style(Style::default().fg(app.theme.muted)) + .alignment(Alignment::Center), + inner, + ); return; } - let mut lines = build_hourly_profile_lines(app, inner.width); + let lines = build_hourly_profile_lines(app, inner.width); let total_lines = lines.len(); let visible_height = inner.height as usize; app.set_hourly_profile_text_viewport(visible_height, total_lines); - - let range = app.hourly_profile_text_visible_range(); - let paragraph = - Paragraph::new(lines.drain(range).collect::>()).alignment(Alignment::Left); - frame.render_widget(paragraph, inner); + let visible = lines[app.hourly_profile_text_visible_range()].to_vec(); + frame.render_widget(Paragraph::new(visible), inner); if total_lines > visible_height { - let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) - .begin_symbol(Some("▲")) - .end_symbol(Some("▼")); - - let mut scrollbar_state = viewport_scrollbar_state( + let mut state = viewport_scrollbar_state( total_lines, app.hourly_profile_viewport.scroll, visible_height, ); - frame.render_stateful_widget( - scrollbar, + Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("▲")) + .end_symbol(Some("▼")), area.inner(Margin { horizontal: 0, vertical: 1, }), - &mut scrollbar_state, + &mut state, ); } } @@ -65,92 +60,65 @@ pub(crate) fn build_hourly_profile_lines(app: &App, area_width: u16) -> Vec mn.format("%Y-%m-%d").to_string(), - (Some(mn), Some(mx)) => format!("{} to {}", mn.format("%Y-%m-%d"), mx.format("%Y-%m-%d")), + (Some(start), Some(end)) if start == end => start.format("%Y-%m-%d").to_string(), + (Some(start), Some(end)) => { + format!("{} to {}", start.format("%Y-%m-%d"), end.format("%Y-%m-%d")) + } _ => "No data".to_string(), }; - // Aggregate data - let periods = aggregate_by_period(hourly); - let weekdays = aggregate_by_weekday(hourly); - let peak_hour = find_peak_hour(hourly); - - // Build content - let mut lines: Vec = Vec::new(); - - // Title line - lines.push(Line::from(vec![ - Span::styled( - "Hourly Profile", - Style::default() - .fg(app.theme.accent) - .add_modifier(Modifier::BOLD), - ), - Span::styled(" ", Style::default()), - Span::styled(date_range, Style::default().fg(app.theme.muted)), - ])); - lines.push(Line::from("")); - - // Summary line - let summary_spans = vec![ - Span::styled( - format!("{} hours", hourly.len()), - Style::default().fg(Color::Cyan), - ), - Span::styled(" | ", Style::default().fg(app.theme.muted)), - Span::styled( - format!("{} total tokens", format_tokens(total_tokens)), - Style::default().fg(Color::Cyan), - ), - Span::styled(" | ", Style::default().fg(app.theme.muted)), - Span::styled( - format!("{} total cost", format_cost(total_cost)), - Style::default().fg(Color::Green), - ), + let mut lines = vec![ + Line::from(vec![ + Span::styled( + "When You Work Most", + Style::default() + .fg(app.theme.accent) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled(date_range, Style::default().fg(app.theme.muted)), + ]), + Line::from(vec![ + Span::styled( + format!("{} active hours", hourly.len()), + Style::default().fg(Color::Cyan), + ), + Span::styled(" · ", Style::default().fg(app.theme.muted)), + Span::styled( + format!("{} tokens", format_tokens(total_tokens)), + Style::default().fg(Color::Cyan), + ), + Span::styled(" · ", Style::default().fg(app.theme.muted)), + Span::styled(format_cost(total_cost), Style::default().fg(Color::Green)), + ]), + Line::default(), ]; - lines.push(Line::from(summary_spans)); - lines.push(Line::from("")); - - // Time-of-day breakdown - lines.push(Line::from(vec![Span::styled( - "When You Work Most", - Style::default() - .fg(app.theme.accent) - .add_modifier(Modifier::BOLD), - )])); - lines.push(Line::from("")); - - let max_period_tokens = periods.iter().map(|p| p.total_tokens).max().unwrap_or(1); - for period in &periods { + let max_period_tokens = periods + .iter() + .map(|period| period.total_tokens) + .max() + .unwrap_or(0); + for period in periods { let percentage = if total_tokens > 0 { period.total_tokens as f64 / total_tokens as f64 * 100.0 } else { 0.0 }; - let bar_filled = if max_period_tokens > 0 { + let filled = if max_period_tokens > 0 { (period.total_tokens as f64 / max_period_tokens as f64 * bar_width as f64).round() as usize } else { 0 - }; - let bar_filled = bar_filled.min(bar_width); - let bar_empty = bar_width - bar_filled; - - let bar = format!("{}{}", "█".repeat(bar_filled), "░".repeat(bar_empty)); - + } + .min(bar_width); lines.push(Line::from(vec![ Span::styled( format!(" {:<10}", period.label), @@ -160,114 +128,60 @@ pub(crate) fn build_hourly_profile_lines(app: &App, area_width: u16) -> Vec12}", period.hour_range), Style::default().fg(app.theme.muted), ), - Span::styled(" ", Style::default()), - Span::styled(bar, Style::default().fg(Color::Green)), - Span::styled(" ", Style::default()), - Span::styled( - format!("{:>5.1}%", percentage), - Style::default().fg(Color::Yellow), - ), - ])); - } - lines.push(Line::from("")); - - // Weekday breakdown - lines.push(Line::from(vec![Span::styled( - "Most Productive Day", - Style::default() - .fg(app.theme.accent) - .add_modifier(Modifier::BOLD), - )])); - lines.push(Line::from("")); - - let max_weekday_tokens = weekdays.iter().map(|w| w.total_tokens).max().unwrap_or(1); - - // Find best weekday - let best_weekday = weekdays - .iter() - .max_by_key(|w| w.total_tokens) - .map(|w| w.day) - .unwrap_or("Monday"); - - for weekday in &weekdays { - let percentage = if total_tokens > 0 { - weekday.total_tokens as f64 / total_tokens as f64 * 100.0 - } else { - 0.0 - }; - let bar_filled = if max_weekday_tokens > 0 { - (weekday.total_tokens as f64 / max_weekday_tokens as f64 * bar_width as f64).round() - as usize - } else { - 0 - }; - let bar_filled = bar_filled.min(bar_width); - let bar_empty = bar_width - bar_filled; - - let bar = format!("{}{}", "█".repeat(bar_filled), "░".repeat(bar_empty)); - - let is_best = weekday.day == best_weekday; - - lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("█".repeat(filled), Style::default().fg(Color::Green)), Span::styled( - format!(" {:<10}", weekday.day), - Style::default().fg(if is_best { - Color::Yellow - } else { - app.theme.foreground - }), + "░".repeat(bar_width.saturating_sub(filled)), + app.theme.subtle_text_style(), ), - Span::styled(bar, Style::default().fg(Color::Green)), - Span::styled(" ", Style::default()), Span::styled( - format!("{:>5.1}%", percentage), + format!(" {:>5.1}%", percentage), Style::default().fg(Color::Yellow), ), ])); } - lines.push(Line::from("")); - // Peak hour insight + lines.push(Line::default()); if let Some((hour, tokens, cost)) = peak_hour { lines.push(Line::from(vec![ Span::styled( - "Peak Hour: ", + "Peak hour ", Style::default() .fg(app.theme.accent) .add_modifier(Modifier::BOLD), ), Span::styled( - format!("{:02}:00-{:02}:59", hour, hour), + format!("{hour:02}:00-{hour:02}:59"), Style::default().fg(Color::Yellow), ), - Span::styled(" (", Style::default().fg(app.theme.muted)), + Span::styled(" · ", Style::default().fg(app.theme.muted)), Span::styled(format_tokens(tokens), Style::default().fg(Color::Cyan)), - Span::styled(" tokens, ", Style::default().fg(app.theme.muted)), + Span::styled(" tokens · ", Style::default().fg(app.theme.muted)), Span::styled(format_cost(cost), Style::default().fg(Color::Green)), - Span::styled(")", Style::default().fg(app.theme.muted)), ])); } + lines.extend([ + Line::default(), + Line::from(vec![ + Span::styled("Press ", Style::default().fg(app.theme.muted)), + Span::styled("[v]", Style::default().fg(Color::Yellow)), + Span::styled( + " to switch to table view", + Style::default().fg(app.theme.muted), + ), + ]), + ]); - // Legend - lines.push(Line::from("")); - lines.push(Line::from(vec![ - Span::styled("Legend: ", Style::default().fg(app.theme.muted)), - Span::styled("░", Style::default().fg(app.theme.muted)), - Span::styled(" low ", Style::default().fg(app.theme.muted)), - Span::styled("█", Style::default().fg(Color::Green)), - Span::styled(" high", Style::default().fg(app.theme.muted)), - ])); + lines +} - // Hint - lines.push(Line::from("")); - lines.push(Line::from(vec![ - Span::styled("Press ", Style::default().fg(app.theme.muted)), - Span::styled("[v]", Style::default().fg(Color::Yellow)), - Span::styled( - " to switch to table view", - Style::default().fg(app.theme.muted), - ), - ])); +#[cfg(test)] +mod tests { + use super::*; - lines + #[test] + fn profile_bar_keeps_a_minimum_width() { + let width = (20usize).saturating_sub(36).clamp(4, 80); + assert_eq!(width, 4); + } } diff --git a/crates/tokscale-cli/src/tui/ui/issues.rs b/crates/tokscale-cli/src/tui/ui/issues.rs index b96aed9c2..ef2ac9a3c 100644 --- a/crates/tokscale-cli/src/tui/ui/issues.rs +++ b/crates/tokscale-cli/src/tui/ui/issues.rs @@ -1,68 +1,54 @@ +use std::collections::{BTreeMap, BTreeSet}; + use ratatui::prelude::*; use ratatui::widgets::{ Block, Borders, Cell, Paragraph, Row, Scrollbar, ScrollbarOrientation, Table, }; -use tokscale_core::source_health::HealthReport; -use unicode_width::UnicodeWidthStr; -use crate::tui::app::App; -use crate::tui::themes::Theme; -use crate::tui::ui::table_layout::{distributed_table_area, DISTRIBUTED_TABLE_FLEX}; -use crate::tui::ui::widgets::{ - format_tokens_with_commas, get_client_display_name, truncate_display_width, +use crate::tui::app::{App, SortDirection, SortField}; + +use super::widgets::{ + format_cost, format_tokens, get_client_display_name, truncate_display_width, viewport_scrollbar_state, }; -const LEVEL_WIDTH: usize = 5; -const SOURCE_MIN_WIDTH: usize = 6; -const SOURCE_MAX_WIDTH: usize = 20; -const ISSUE_MIN_WIDTH: usize = 8; -const ISSUE_MAX_WIDTH: usize = 32; -const SOURCES_MIN_WIDTH: usize = 7; -const SOURCES_MAX_WIDTH: usize = 10; -const RECORDS_MIN_WIDTH: usize = 7; -const RECORDS_MAX_WIDTH: usize = 10; -const HANDLING_MIN_WIDTH: usize = 8; -const HANDLING_MAX_WIDTH: usize = 20; -const DETAILS_TABLE_PREFERRED_WIDTH: u16 = 110; -const TABLE_COLUMN_SPACING: usize = 2; -const SUMMARY_DESCRIPTIONS_MIN_WIDTH: usize = 96; -const SUMMARY_GROUP_WIDTH: usize = 9; -const SUMMARY_STATUS_WIDTH: usize = 10; -const SUMMARY_VALUE_WIDTH: usize = 10; - -pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { - let summary_width = area.width.saturating_sub(4) as usize; - let summary = summary_lines(&app.theme, &app.data.health, summary_width); - let rows = issue_rows(&app.data.health); - let [summary_area, details_area] = issue_panel_areas(area, summary.len(), rows.len()); +#[derive(Debug, Clone)] +struct SessionCoverageRow { + harness: String, + model: String, + sessions: u32, + tokens: u64, + cost: f64, +} - render_summary(frame, app, summary_area, summary); - render_details(frame, app, details_area, rows); +#[derive(Debug, Clone, Default)] +struct HarnessCoverage { + tokens: u64, + cost: f64, + models: BTreeSet, + active_days: BTreeSet, } -fn issue_panel_areas(area: Rect, summary_lines: usize, issue_rows: usize) -> [Rect; 2] { - let summary_height = summary_lines.saturating_add(2).min(usize::from(u16::MAX)) as u16; - let summary_height = summary_height.min(area.height); - let summary_area = Rect::new(area.x, area.y, area.width, summary_height); +pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { + if area.is_empty() { + return; + } - let remaining_height = area.height.saturating_sub(summary_height); - let details_content_height = if issue_rows == 0 { - 1 - } else { - issue_rows.saturating_add(1) - }; - let details_height = details_content_height - .saturating_add(2) - .min(usize::from(remaining_height)) as u16; - let details_area = Rect::new( - area.x, - area.y.saturating_add(summary_height), - area.width, - details_height, - ); + if area.height < 10 { + render_session_coverage(frame, app, area); + return; + } - [summary_area, details_area] + let health_height = if area.height >= 18 { 9 } else { 6 }; + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(4), + Constraint::Length(health_height.min(area.height)), + ]) + .split(area); + render_session_coverage(frame, app, chunks[0]); + render_harness_health(frame, app, chunks[1]); } fn panel_block<'a>(app: &App, title: &'a str) -> Block<'a> { @@ -78,26 +64,72 @@ fn panel_block<'a>(app: &App, title: &'a str) -> Block<'a> { .style(Style::default().bg(app.theme.background)) } -fn render_summary(frame: &mut Frame, app: &App, area: Rect, lines: Vec>) { - let block = panel_block(app, "Summary"); - let inner = distributed_table_area(block.inner(area)); - frame.render_widget(block, area); - frame.render_widget(Paragraph::new(lines), inner); +fn session_coverage_rows(app: &App) -> Vec { + let mut rows = app + .data + .models + .iter() + .map(|model| SessionCoverageRow { + harness: display_harnesses(&model.client), + model: model + .workspace_label + .as_ref() + .map(|workspace| format!("{workspace} / {}", model.model)) + .unwrap_or_else(|| model.model.clone()), + sessions: model.session_count, + tokens: model.tokens.total(), + cost: model.cost, + }) + .collect::>(); + + rows.sort_by(|left, right| { + let ordering = match app.sort_field { + SortField::Cost => left.cost.total_cmp(&right.cost), + SortField::Tokens => left.tokens.cmp(&right.tokens), + SortField::Date => left.sessions.cmp(&right.sessions), + }; + let ordering = match app.sort_direction { + SortDirection::Ascending => ordering, + SortDirection::Descending => ordering.reverse(), + }; + ordering + .then_with(|| left.harness.cmp(&right.harness)) + .then_with(|| left.model.cmp(&right.model)) + }); + rows } -fn render_details(frame: &mut Frame, app: &mut App, area: Rect, rows: Vec) { - let block = panel_block(app, "Details"); - let inner = distributed_table_area(block.inner(area)); - frame.render_widget(block, area); +fn display_harnesses(raw: &str) -> String { + raw.split(", ") + .map(get_client_display_name) + .collect::>() + .join(", ") +} - if inner.width == 0 || inner.height == 0 { +fn render_session_coverage(frame: &mut Frame, app: &mut App, area: Rect) { + let rows = session_coverage_rows(app); + let session_links = rows.iter().fold(0u64, |total, row| { + total.saturating_add(u64::from(row.sessions)) + }); + let block = panel_block(app, "Session Coverage").title_top( + Line::from(Span::styled( + format!(" {session_links} model-session links "), + Style::default().fg(app.theme.muted), + )) + .right_aligned(), + ); + let inner = block.inner(area); + frame.render_widget(block, area); + if inner.is_empty() { return; } if rows.is_empty() { app.set_issues_text_viewport(inner.height as usize, 0); frame.render_widget( - Paragraph::new("No data issues found.").style(Style::default().fg(app.theme.muted)), + Paragraph::new("No session coverage data available") + .style(Style::default().fg(app.theme.muted)) + .alignment(Alignment::Center), inner, ); return; @@ -105,38 +137,65 @@ fn render_details(frame: &mut Frame, app: &mut App, area: Rect, rows: Vec>(); let header = Row::new(vec![ - Cell::from("LEVEL"), - Cell::from("SOURCE"), - Cell::from(Line::from("ISSUE").centered()), - Cell::from(Line::from("SOURCES").centered()), - Cell::from(Line::from("RECORDS").centered()), - Cell::from("HANDLING"), + "Harness", + "Model / Workspace", + "Sessions", + "Tokens", + "Cost", ]) .style( Style::default() .fg(app.theme.accent) .add_modifier(Modifier::BOLD), - ); - let table = Table::new(visible_rows, layout.constraints()) - .header(header) - .column_spacing(TABLE_COLUMN_SPACING as u16) - .flex(DISTRIBUTED_TABLE_FLEX); - frame.render_widget(table, details_table_area(inner, layout)); + ) + .height(1); + let widths = if app.is_narrow() { + [ + Constraint::Percentage(24), + Constraint::Percentage(38), + Constraint::Length(9), + Constraint::Length(11), + Constraint::Length(10), + ] + } else { + [ + Constraint::Percentage(24), + Constraint::Percentage(42), + Constraint::Length(10), + Constraint::Length(13), + Constraint::Length(12), + ] + }; + frame.render_widget(Table::new(visible_rows, widths).header(header), inner); if rows.len() > visible_height { - let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) - .begin_symbol(Some("▲")) - .end_symbol(Some("▼")); - let mut state = - viewport_scrollbar_state(rows.len(), app.issues_viewport.scroll, visible_height); + let mut state = viewport_scrollbar_state( + rows.len(), + app.issues_viewport.scroll, + visible_height.max(1), + ); frame.render_stateful_widget( - scrollbar, + Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("▲")) + .end_symbol(Some("▼")), area.inner(Margin { horizontal: 0, vertical: 1, @@ -146,783 +205,196 @@ fn render_details(frame: &mut Frame, app: &mut App, area: Rect, rows: Vec Vec { - health - .issues - .iter() - .map(|issue| { - let (level, color) = match issue.level.as_str() { - "warning" => ("WARN", Color::Yellow), - "error" => ("ERROR", Color::Red), - other => panic!("unsupported health issue level `{other}`"), - }; - IssueRow { - level, - color, - source: get_client_display_name(&issue.source), - issue: issue_label(&issue.issue).to_string(), - affected_sources: issue.affected_sources, - rejected_records: issue.rejected_records, - handling: handling_label(&issue.handling), +fn harness_coverage(app: &App) -> BTreeMap { + let mut harnesses = BTreeMap::::new(); + for day in &app.data.daily { + for (harness, source) in &day.source_breakdown { + let entry = harnesses.entry(harness.clone()).or_default(); + entry.tokens = entry + .tokens + .checked_add(source.tokens.total()) + .expect("harness coverage token total exceeds u64::MAX"); + entry.cost += source.cost; + if source.tokens.total() > 0 { + entry.active_days.insert(day.date); } - }) - .collect() -} - -fn issue_label(key: &str) -> &str { - match key { - "missing-model" => "Missing Model", - "missing-provider" => "Missing Provider", - "missing-timestamp" => "Missing Timestamp", - "malformed-record" => "Malformed Record", - "partial-source" => "Partial Source", - "source-unavailable" => "Source Unavailable", - other => other, - } -} - -fn handling_label(key: &str) -> &'static str { - match key { - "record-skipped" => "Record Skipped", - "confirmed-data-kept" => "Confirmed Data Kept", - "source-skipped" => "Source Skipped", - other => panic!("unsupported health issue handling `{other}`"), + for (model_key, model) in &source.models { + entry.models.insert(if model.color_key.is_empty() { + model_key.clone() + } else { + model.color_key.clone() + }); + } + } } + harnesses } -fn details_table_area(area: Rect, layout: IssueTableLayout) -> Rect { - Rect { - width: area - .width - .min((layout.rendered_width() as u16).max(DETAILS_TABLE_PREFERRED_WIDTH)), - ..area +fn render_harness_health(frame: &mut Frame, app: &App, area: Rect) { + let block = panel_block(app, "Harnesses & Source Health"); + let inner = block.inner(area); + frame.render_widget(block, area); + if inner.is_empty() { + return; } -} - -fn record_table_row(theme: &Theme, row: &IssueRow, layout: IssueTableLayout) -> Row<'static> { - Row::new(vec![ - Cell::from(row.level).style(Style::default().fg(row.color).add_modifier(Modifier::BOLD)), - Cell::from(truncate_display_width(&row.source, layout.source)) - .style(Style::default().fg(theme.muted)), - Cell::from(Line::from(truncate_display_width(&row.issue, layout.issue)).centered()) - .style(Style::default().fg(theme.foreground)), - Cell::from(Line::from(row.affected_sources.to_string()).centered()) - .style(Style::default().fg(row.color)), - Cell::from( - Line::from( - row.rejected_records - .map_or_else(|| "—".to_string(), |count| count.to_string()), - ) - .centered(), - ) - .style(Style::default().fg(if row.rejected_records.is_some() { - row.color - } else { - theme.muted - })), - Cell::from(truncate_display_width(row.handling, layout.handling)) - .style(Style::default().fg(theme.foreground)), - ]) -} - -struct SummaryStatus { - group: &'static str, - label: &'static str, - value: String, - color: Color, - explanation: &'static str, -} - -fn summary_lines( - theme: &Theme, - health: &HealthReport, - available_width: usize, -) -> Vec> { - let show_explanations = available_width >= SUMMARY_DESCRIPTIONS_MIN_WIDTH; - let source_statuses = [ - SummaryStatus { - group: "Sources", - label: "Clean", - value: format_tokens_with_commas(health.clean_sources as u64), - color: if health.clean_sources > 0 { - theme.accent - } else { - theme.muted - }, - explanation: "Scan completed; no records rejected", - }, - SummaryStatus { - group: "", - label: "Degraded", - value: format_tokens_with_commas(health.degraded_sources as u64), - color: if health.degraded_sources > 0 { - Color::Yellow - } else { - theme.muted - }, - explanation: "Scan completed; invalid records skipped", - }, - SummaryStatus { - group: "", - label: "Partial", - value: format_tokens_with_commas(health.partial_sources as u64), - color: if health.partial_sources > 0 { - Color::Red - } else { - theme.muted - }, - explanation: "Scan interrupted; confirmed data kept", - }, - SummaryStatus { - group: "", - label: "Failed", - value: format_tokens_with_commas(health.failed_sources as u64), - color: if health.failed_sources > 0 { - Color::Red - } else { - theme.muted - }, - explanation: "Source unavailable; source skipped", - }, - ]; - let record_status = SummaryStatus { - group: "Records", - label: "Rejected", - value: format_tokens_with_commas(health.rejected_records), - color: if health.rejected_records > 0 { - Color::Yellow - } else { - theme.muted - }, - explanation: "Invalid records skipped", - }; + let health = &app.data.health; + let total_sources = health.clean_sources + + health.degraded_sources + + health.partial_sources + + health.failed_sources; + let issue_count = health.issue_count(); let mut lines = vec![ - source_health_line(theme, health, show_explanations), - source_data_line(theme, health.source_data_bytes, show_explanations), - Line::default(), + Line::from(vec![ + Span::styled("Source data ", Style::default().fg(app.theme.muted)), + Span::styled( + format_bytes(health.source_data_bytes), + Style::default().fg(app.theme.foreground), + ), + Span::styled(" · Issues ", Style::default().fg(app.theme.muted)), + Span::styled( + issue_count.to_string(), + Style::default().fg(if issue_count == 0 { + app.theme.muted + } else { + Color::Yellow + }), + ), + Span::styled(" · Rejected ", Style::default().fg(app.theme.muted)), + Span::styled( + health.rejected_records.to_string(), + Style::default().fg(if health.rejected_records == 0 { + app.theme.muted + } else { + Color::Yellow + }), + ), + ]), + Line::from(vec![ + Span::styled("Sources ", Style::default().fg(app.theme.muted)), + Span::styled(total_sources.to_string(), Style::default().fg(Color::Cyan)), + Span::styled(" · clean ", Style::default().fg(app.theme.muted)), + Span::styled( + health.clean_sources.to_string(), + Style::default().fg(Color::Green), + ), + Span::styled(" · degraded ", Style::default().fg(app.theme.muted)), + Span::styled( + health.degraded_sources.to_string(), + Style::default().fg(if health.degraded_sources == 0 { + app.theme.muted + } else { + Color::Yellow + }), + ), + Span::styled(" · partial/failed ", Style::default().fg(app.theme.muted)), + Span::styled( + (health.partial_sources + health.failed_sources).to_string(), + Style::default().fg(if health.partial_sources + health.failed_sources == 0 { + app.theme.muted + } else { + Color::Red + }), + ), + ]), ]; - lines.extend( - source_statuses - .iter() - .map(|status| summary_status_line(theme, status, show_explanations)), - ); - lines.push(summary_status_line( - theme, - &record_status, - show_explanations, - )); - lines -} - -fn source_health_line( - theme: &Theme, - health: &HealthReport, - show_explanation: bool, -) -> Line<'static> { - let total_sources = total_sources(health); - let percentage = source_health_percentage(health); - let health_color = source_health_color(theme, health, total_sources); - let mut spans = vec![Span::styled( - format!( - "{:width$}", "—", width = SUMMARY_VALUE_WIDTH), - Style::default().fg(theme.muted), - )); - } else { - spans.push(Span::styled( - format!(" {:>8} ", percentage), - Style::default() - .fg(theme.background) - .bg(health_color) - .add_modifier(Modifier::BOLD), - )); - } - if show_explanation { - let explanation = if total_sources == 0 { - "No source units discovered at the latest refresh.".to_string() - } else { - format!( - "{} of {} source units are clean at the latest refresh.", - format_tokens_with_commas(health.clean_sources as u64), - format_tokens_with_commas(total_sources as u64), - ) - }; - spans.push(Span::styled(" ", Style::default())); - spans.push(Span::styled(explanation, Style::default().fg(theme.muted))); - } - Line::from(spans) -} -fn source_data_line( - theme: &Theme, - source_data_bytes: u64, - show_explanation: bool, -) -> Line<'static> { - let mut spans = vec![ - Span::styled( - format!("{:width$}", - format_source_data_bytes(source_data_bytes), - width = SUMMARY_VALUE_WIDTH + if !health.issues.is_empty() { + let mut affected = BTreeMap::::new(); + for issue in &health.issues { + let count = u64::try_from(issue.affected_sources).unwrap_or(u64::MAX); + let entry = affected.entry(issue.source.clone()).or_insert(0); + *entry = entry.saturating_add(count); + } + let labels = affected + .into_iter() + .map(|(source, count)| format!("{} ({count})", get_client_display_name(&source))) + .collect::>() + .join(", "); + lines.push(Line::from(vec![ + Span::styled( + "Affected source groups: ", + Style::default().fg(app.theme.muted), ), - Style::default() - .fg(theme.foreground) - .add_modifier(Modifier::BOLD), - ), - ]; - if show_explanation { - spans.push(Span::styled(" ", Style::default())); - spans.push(Span::styled( - "Current on-disk footprint; tokscale cache excluded.", - Style::default().fg(theme.muted), - )); + Span::styled(labels, Style::default().fg(Color::Yellow)), + ])); + } + + let mut harnesses = harness_coverage(app).into_iter().collect::>(); + harnesses.sort_by(|(left_name, left), (right_name, right)| { + right + .tokens + .cmp(&left.tokens) + .then_with(|| right.cost.total_cmp(&left.cost)) + .then_with(|| left_name.cmp(right_name)) + }); + for (harness, coverage) in harnesses { + lines.push(Line::from(vec![ + Span::styled( + format!("{} ", get_client_display_name(&harness)), + Style::default() + .fg(app.theme.foreground) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + format!("{} models", coverage.models.len()), + Style::default().fg(app.theme.muted), + ), + Span::styled(" · ", Style::default().fg(app.theme.muted)), + Span::styled( + format!("{} active days", coverage.active_days.len()), + Style::default().fg(app.theme.muted), + ), + Span::styled(" · ", Style::default().fg(app.theme.muted)), + Span::styled( + format_tokens(coverage.tokens), + Style::default().fg(Color::Cyan), + ), + Span::styled(" · ", Style::default().fg(app.theme.muted)), + Span::styled( + format_cost(coverage.cost), + Style::default().fg(Color::Green), + ), + ])); } - Line::from(spans) -} -fn summary_status_line( - theme: &Theme, - status: &SummaryStatus, - show_explanation: bool, -) -> Line<'static> { - let mut spans = vec![ - Span::styled( - format!("{:width$}", status.value, width = SUMMARY_VALUE_WIDTH), - Style::default() - .fg(status.color) - .add_modifier(Modifier::BOLD), + frame.render_widget( + Paragraph::new( + lines + .into_iter() + .take(inner.height as usize) + .collect::>(), ), - ]; - if show_explanation { - spans.push(Span::styled(" ", Style::default())); - spans.push(Span::styled( - status.explanation, - Style::default().fg(theme.muted), - )); - } - Line::from(spans) -} - -fn total_sources(health: &HealthReport) -> usize { - health - .clean_sources - .checked_add(health.degraded_sources) - .and_then(|total| total.checked_add(health.partial_sources)) - .and_then(|total| total.checked_add(health.failed_sources)) - .expect("source count must fit in usize") -} - -fn source_health_percentage(health: &HealthReport) -> String { - let total = total_sources(health); - if total == 0 { - return "—".to_string(); - } - if health.clean_sources == total { - return "100%".to_string(); - } - format!("{:.2}%", health.clean_sources as f64 / total as f64 * 100.0) + inner, + ); } -fn source_health_color(theme: &Theme, health: &HealthReport, total_sources: usize) -> Color { - if total_sources == 0 { - theme.muted - } else if health.clean_sources as f64 / total_sources as f64 >= 0.99 { - Color::Green - } else if health.clean_sources as f64 / total_sources as f64 >= 0.95 { - Color::Yellow - } else { - Color::Red +fn format_bytes(bytes: u64) -> String { + const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; + let mut value = bytes as f64; + let mut unit = 0usize; + while value >= 1024.0 && unit + 1 < UNITS.len() { + value /= 1024.0; + unit += 1; } -} - -fn format_source_data_bytes(bytes: u64) -> String { - const KIB: u64 = 1024; - const MIB: u64 = KIB * 1024; - const GIB: u64 = MIB * 1024; - const TIB: u64 = GIB * 1024; - - let (unit, divisor) = if bytes >= TIB { - ("TiB", TIB) - } else if bytes >= GIB { - ("GiB", GIB) - } else if bytes >= MIB { - ("MiB", MIB) - } else if bytes >= KIB { - ("KiB", KIB) + if unit == 0 { + format!("{bytes} {}", UNITS[unit]) } else { - return format!("{} B", format_tokens_with_commas(bytes)); - }; - format!("{:.1} {unit}", bytes as f64 / divisor as f64) -} - -#[derive(Debug, PartialEq, Eq)] -struct IssueRow { - level: &'static str, - color: Color, - source: String, - issue: String, - affected_sources: u64, - rejected_records: Option, - handling: &'static str, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct IssueTableLayout { - level: usize, - source: usize, - issue: usize, - sources: usize, - records: usize, - handling: usize, -} - -impl IssueTableLayout { - fn for_rows(width: usize, rows: &[IssueRow]) -> Self { - let sources = rows - .iter() - .map(|row| row.affected_sources.to_string().width()) - .chain(std::iter::once("SOURCES".width())) - .max() - .unwrap_or(SOURCES_MIN_WIDTH) - .clamp(SOURCES_MIN_WIDTH, SOURCES_MAX_WIDTH); - let records = rows - .iter() - .filter_map(|row| row.rejected_records) - .map(|count| count.to_string().width()) - .chain(std::iter::once("RECORDS".width())) - .max() - .unwrap_or(RECORDS_MIN_WIDTH) - .clamp(RECORDS_MIN_WIDTH, RECORDS_MAX_WIDTH); - let source = rows - .iter() - .map(|row| row.source.width()) - .chain(std::iter::once("SOURCE".width())) - .max() - .unwrap_or(SOURCE_MIN_WIDTH) - .clamp(SOURCE_MIN_WIDTH, SOURCE_MAX_WIDTH); - let issue = rows - .iter() - .map(|row| row.issue.width()) - .chain(std::iter::once("ISSUE".width())) - .max() - .unwrap_or(ISSUE_MIN_WIDTH) - .clamp(ISSUE_MIN_WIDTH, ISSUE_MAX_WIDTH); - let handling = rows - .iter() - .map(|row| row.handling.width()) - .chain(std::iter::once("HANDLING".width())) - .max() - .unwrap_or(HANDLING_MIN_WIDTH) - .clamp(HANDLING_MIN_WIDTH, HANDLING_MAX_WIDTH); - - let mut layout = Self { - level: LEVEL_WIDTH, - source, - issue, - sources, - records, - handling, - }; - layout.shrink_to(width); - layout - } - - fn shrink_to(&mut self, width: usize) { - let mut excess = self.rendered_width().saturating_sub(width); - shrink_column(&mut self.issue, ISSUE_MIN_WIDTH, &mut excess); - shrink_column(&mut self.source, SOURCE_MIN_WIDTH, &mut excess); - shrink_column(&mut self.handling, HANDLING_MIN_WIDTH, &mut excess); - } - - fn constraints(self) -> [Constraint; 6] { - [ - Constraint::Length(self.level as u16), - Constraint::Length(self.source as u16), - Constraint::Length(self.issue as u16), - Constraint::Length(self.sources as u16), - Constraint::Length(self.records as u16), - Constraint::Length(self.handling as u16), - ] - } - - fn rendered_width(self) -> usize { - self.level - + self.source - + self.issue - + self.sources - + self.records - + self.handling - + TABLE_COLUMN_SPACING * 5 + format!("{value:.1} {}", UNITS[unit]) } } -fn shrink_column(column: &mut usize, minimum: usize, excess: &mut usize) { - let reduction = (*column).saturating_sub(minimum).min(*excess); - *column -= reduction; - *excess -= reduction; -} - #[cfg(test)] mod tests { use super::*; - use tokscale_core::source_health::HealthIssueReport; - - use crate::tui::config::TokscaleConfig; - use crate::tui::themes::ThemeName; - - fn line_text(line: &Line<'_>) -> String { - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect() - } - - fn issue_report() -> HealthReport { - TokscaleConfig::initialize_default_for_tests(); - HealthReport { - complete: false, - clean_sources: 10_190, - degraded_sources: 1, - rejected_records: 1, - partial_sources: 1, - failed_sources: 1, - source_data_bytes: 2_684_354_560, - issues: vec![ - HealthIssueReport { - level: "warning".to_string(), - source: "zed".to_string(), - issue: "missing-model".to_string(), - affected_sources: 1, - rejected_records: Some(1), - handling: "record-skipped".to_string(), - }, - HealthIssueReport { - level: "error".to_string(), - source: "opencode".to_string(), - issue: "source-unavailable".to_string(), - affected_sources: 1, - rejected_records: None, - handling: "source-skipped".to_string(), - }, - HealthIssueReport { - level: "error".to_string(), - source: "claude".to_string(), - issue: "partial-source".to_string(), - affected_sources: 1, - rejected_records: None, - handling: "confirmed-data-kept".to_string(), - }, - ], - } - } #[test] - fn issue_rows_render_compact_records_without_samples_or_paths() { - let rows = issue_rows(&issue_report()); - - assert_eq!( - rows, - vec![ - IssueRow { - level: "WARN", - color: Color::Yellow, - source: "Zed Agent".to_string(), - issue: "Missing Model".to_string(), - affected_sources: 1, - rejected_records: Some(1), - handling: "Record Skipped", - }, - IssueRow { - level: "ERROR", - color: Color::Red, - source: "OpenCode".to_string(), - issue: "Source Unavailable".to_string(), - affected_sources: 1, - rejected_records: None, - handling: "Source Skipped", - }, - IssueRow { - level: "ERROR", - color: Color::Red, - source: "Claude".to_string(), - issue: "Partial Source".to_string(), - affected_sources: 1, - rejected_records: None, - handling: "Confirmed Data Kept", - }, - ] - ); - let rendered = format!("{rows:?}"); - assert!(!rendered.contains("thread bad-1")); - assert!(!rendered.contains("database is corrupt")); - assert!(!rendered.contains("/tmp/")); - } - - #[test] - fn healthy_report_has_no_issue_rows() { - assert!(issue_rows(&HealthReport::default()).is_empty()); - } - - #[test] - fn preaggregated_failure_renders_as_one_visible_row() { - TokscaleConfig::initialize_default_for_tests(); - let health = HealthReport { - complete: false, - failed_sources: 5, - issues: vec![HealthIssueReport { - level: "error".to_string(), - source: "kiro".to_string(), - issue: "source-unavailable".to_string(), - affected_sources: 5, - rejected_records: None, - handling: "source-skipped".to_string(), - }], - ..HealthReport::default() - }; - - assert_eq!( - issue_rows(&health), - vec![IssueRow { - level: "ERROR", - color: Color::Red, - source: "Kiro".to_string(), - issue: "Source Unavailable".to_string(), - affected_sources: 5, - rejected_records: None, - handling: "Source Skipped", - }] - ); + fn combined_harness_names_are_displayed_individually() { + assert_eq!(display_harnesses("claude, codex"), "Claude, Codex"); } #[test] - fn record_rows_keep_source_and_record_counts_separate() { - TokscaleConfig::initialize_default_for_tests(); - let health = HealthReport { - complete: false, - degraded_sources: 5, - rejected_records: 37, - issues: vec![HealthIssueReport { - level: "warning".to_string(), - source: "kiro".to_string(), - issue: "missing-model".to_string(), - affected_sources: 5, - rejected_records: Some(37), - handling: "record-skipped".to_string(), - }], - ..HealthReport::default() - }; - - let rows = issue_rows(&health); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].affected_sources, 5); - assert_eq!(rows[0].rejected_records, Some(37)); - assert_eq!(rows[0].handling, "Record Skipped"); - } - - #[test] - fn wide_summary_explains_source_and_record_statuses() { - let theme = Theme::from_name_for_current_terminal(ThemeName::Blue); - let health = issue_report(); - - let lines = summary_lines(&theme, &health, 120); - assert_eq!(lines.len(), 8); - assert!(line_text(&lines[0]).contains("99.97%")); - assert!(line_text(&lines[0]) - .contains("10,190 of 10,193 source units are clean at the latest refresh.")); - assert!(line_text(&lines[1]).contains("2.5 GiB")); - assert!(line_text(&lines[1]).contains("tokscale cache excluded")); - assert!(line_text(&lines[2]).is_empty()); - assert!(line_text(&lines[3]).contains("Sources")); - assert!(line_text(&lines[3]).contains("Clean")); - assert!(line_text(&lines[3]).contains("Scan completed; no records rejected")); - assert!(line_text(&lines[4]).contains("Degraded")); - assert!(line_text(&lines[5]).contains("Partial")); - assert!(line_text(&lines[6]).contains("Failed")); - assert!(line_text(&lines[7]).contains("Records")); - assert!(line_text(&lines[7]).contains("Rejected")); - } - - #[test] - fn narrow_summary_hides_explanations_without_hiding_statuses() { - let theme = Theme::from_name_for_current_terminal(ThemeName::Blue); - let lines = summary_lines(&theme, &issue_report(), 80); - let rendered = lines.iter().map(line_text).collect::>().join("\n"); - - assert!(rendered.contains("99.97%")); - assert!(rendered.contains("2.5 GiB")); - assert!(rendered.contains("Clean")); - assert!(rendered.contains("Rejected")); - assert!(!rendered.contains("latest refresh")); - assert!(!rendered.contains("Scan completed")); - assert!(!rendered.contains("tokscale cache excluded")); - } - - #[test] - fn issue_free_statuses_are_muted_and_near_perfect_health_is_green() { - let theme = Theme::from_name_for_current_terminal(ThemeName::Blue); - let lines = summary_lines(&theme, &HealthReport::default(), 120); - let metric_spans = lines - .iter() - .flat_map(|line| &line.spans) - .filter(|span| { - matches!( - span.content.trim(), - "Degraded" | "Partial" | "Failed" | "Rejected" - ) - }) - .collect::>(); - - assert_eq!(metric_spans.len(), 4); - assert!(metric_spans - .iter() - .all(|span| span.style.fg == Some(theme.muted))); - - let failed_lines = summary_lines(&theme, &issue_report(), 120); - let percentage = failed_lines[0] - .spans - .iter() - .find(|span| span.content.contains('%')) - .unwrap(); - assert_eq!(percentage.style.bg, Some(Color::Green)); - } - - #[test] - fn health_and_disk_descriptions_start_in_the_same_column() { - let theme = Theme::from_name_for_current_terminal(ThemeName::Blue); - let lines = summary_lines(&theme, &issue_report(), 120); - let description_start = |line: &Line<'_>| { - line.spans - .iter() - .take(3) - .map(|span| span.content.width()) - .sum::() - }; - - assert_eq!(description_start(&lines[0]), description_start(&lines[1])); - } - - #[test] - fn source_health_percentage_uses_clean_sources_over_all_sources() { - assert_eq!(source_health_percentage(&HealthReport::default()), "—"); - assert_eq!( - source_health_percentage(&HealthReport { - clean_sources: 3, - ..HealthReport::default() - }), - "100%" - ); - assert_eq!( - source_health_percentage(&HealthReport { - clean_sources: 3, - degraded_sources: 1, - ..HealthReport::default() - }), - "75.00%" - ); - } - - #[test] - fn source_health_color_follows_percentage_bands() { - let theme = Theme::from_name_for_current_terminal(ThemeName::Blue); - let health = |clean_sources, degraded_sources| HealthReport { - clean_sources, - degraded_sources, - ..HealthReport::default() - }; - - assert_eq!( - source_health_color(&theme, &health(99, 1), 100), - Color::Green - ); - assert_eq!( - source_health_color(&theme, &health(95, 5), 100), - Color::Yellow - ); - assert_eq!(source_health_color(&theme, &health(94, 6), 100), Color::Red); - } - - #[test] - fn source_data_size_uses_binary_disk_units() { - assert_eq!(format_source_data_bytes(0), "0 B"); - assert_eq!(format_source_data_bytes(1_023), "1,023 B"); - assert_eq!(format_source_data_bytes(1_536), "1.5 KiB"); - assert_eq!(format_source_data_bytes(2_684_354_560), "2.5 GiB"); - } - - #[test] - fn details_table_layout_is_compact_and_shrinks_descriptive_columns() { - let rows = issue_rows(&issue_report()); - - let wide = IssueTableLayout::for_rows(160, &rows); - assert_eq!(wide.rendered_width(), 75); - assert_eq!( - details_table_area(Rect::new(4, 2, 160, 20), wide).width, - 110 - ); - - let medium = IssueTableLayout::for_rows(60, &rows); - assert_eq!(medium.rendered_width(), 60); - assert_eq!( - details_table_area(Rect::new(4, 2, 60, 20), medium).width, - 60 - ); - assert!(medium.issue < wide.issue); - assert!(medium.source < wide.source); - assert!(medium.handling < wide.handling); - - let narrow = IssueTableLayout::for_rows(51, &rows); - assert_eq!(narrow.rendered_width(), 51); - assert_eq!(narrow.issue, ISSUE_MIN_WIDTH); - assert_eq!(narrow.source, SOURCE_MIN_WIDTH); - assert_eq!(narrow.handling, HANDLING_MIN_WIDTH); - } - - #[test] - fn details_panel_height_tracks_content_and_caps_at_available_height() { - let area = Rect::new(2, 3, 120, 40); - - let [summary, details] = issue_panel_areas(area, 8, 3); - assert_eq!(summary.height, 10); - assert_eq!(details.y, 13); - assert_eq!(details.height, 6); - assert!(details.bottom() < area.bottom()); - - let [_, empty_details] = issue_panel_areas(area, 8, 0); - assert_eq!(empty_details.height, 3); - - let [_, overflowing_details] = issue_panel_areas(area, 8, 100); - assert_eq!(overflowing_details.height, 30); - assert_eq!(overflowing_details.bottom(), area.bottom()); + fn source_size_uses_binary_units() { + assert_eq!(format_bytes(1024), "1.0 KiB"); } } diff --git a/crates/tokscale-cli/src/tui/ui/mod.rs b/crates/tokscale-cli/src/tui/ui/mod.rs index cd2be7c2c..0cf395eed 100644 --- a/crates/tokscale-cli/src/tui/ui/mod.rs +++ b/crates/tokscale-cli/src/tui/ui/mod.rs @@ -1,6 +1,7 @@ mod agents; mod bar_chart; mod daily; +mod daily_profile; pub mod dialog; mod footer; mod header; @@ -51,7 +52,7 @@ pub fn render(frame: &mut Frame, app: &mut App) { Tab::Overview => overview::render(frame, app, chunks[1]), Tab::Models => models::render(frame, app, chunks[1]), Tab::Agents => agents::render(frame, app, chunks[1]), - Tab::Daily => daily::render(frame, app, chunks[1]), + Tab::Daily => render_daily(frame, app, chunks[1]), Tab::Hourly => hourly::render(frame, app, chunks[1]), Tab::Monthly => period::render_monthly(frame, app, chunks[1]), Tab::Weekly => period::render_weekly(frame, app, chunks[1]), @@ -68,6 +69,23 @@ pub fn render(frame: &mut Frame, app: &mut App) { } } +fn render_daily(frame: &mut Frame, app: &mut App, area: Rect) { + if app.is_daily_detail_active() || area.height < daily_profile::MIN_COMBINED_HEIGHT { + daily::render(frame, app, area); + return; + } + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(daily_profile::PANEL_HEIGHT), + Constraint::Min(0), + ]) + .split(area); + daily_profile::render(frame, app, chunks[0]); + daily::render(frame, app, chunks[1]); +} + fn render_loading(frame: &mut Frame, app: &App, area: Rect) { let block = Block::default() .borders(Borders::ALL) diff --git a/crates/tokscale-cli/src/tui/ui/overview.rs b/crates/tokscale-cli/src/tui/ui/overview.rs index 82cfa69a0..2a529eed4 100644 --- a/crates/tokscale-cli/src/tui/ui/overview.rs +++ b/crates/tokscale-cli/src/tui/ui/overview.rs @@ -1,78 +1,122 @@ +use std::collections::{BTreeMap, BTreeSet}; + use ratatui::prelude::*; -use ratatui::widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation}; +use ratatui::widgets::{Block, Borders, Paragraph}; use super::bar_chart::{render_stacked_bar_chart, ModelSegment, StackedBarData}; -use super::widgets::{format_tokens, viewport_scrollbar_state}; +use super::widgets::{format_cost, format_tokens, get_client_display_name}; use crate::tui::app::{App, ChartGranularity}; -use tokscale_core::GroupBy; +use crate::tui::data::TokenBreakdown; -struct ModelRowData { - model: String, +#[derive(Debug, Clone, Default)] +struct ModelAggregate { provider: String, - workspace_label: Option, - tokens_input: u64, - displayed_output: u64, - tokens_cache_read: u64, - tokens_cache_write: u64, + tokens: u64, cost: f64, } -fn overview_model_label(group_by: &GroupBy, model: &str, workspace_label: Option<&str>) -> String { - if *group_by == GroupBy::WorkspaceModel { - format!( - "{} / {}", - workspace_label.unwrap_or("Unknown workspace"), - model - ) - } else { - model.to_string() - } +#[derive(Debug, Clone, Default)] +struct HarnessAggregate { + tokens: u64, + cost: f64, + models: BTreeSet, } -fn overview_color_key<'a>(group_by: &GroupBy, model: &'a str) -> &'a str { - if *group_by == GroupBy::WorkspaceModel { - model - .rsplit_once(" / ") - .map(|(_, base_model)| base_model) - .unwrap_or(model) - } else { - model - } +#[derive(Debug, Clone, Default)] +struct OverviewData { + models: BTreeMap, + harnesses: BTreeMap, + tokens: TokenBreakdown, + active_days: usize, } pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { - // Pre-fill entire overview area with theme background so that chart and - // legend cells (which only set fg via direct buffer writes) don't fall - // through to the terminal's default background color. frame.render_widget( Block::default().style(Style::default().bg(app.theme.background)), area, ); - let safe_height = area.height.max(12) as usize; - let chart_height = (safe_height as f64 * 0.35).floor().max(5.0) as u16; - let legend_height = 1u16; + if area.is_empty() { + return; + } + app.set_max_visible_items(1); + let chart_height = if area.height >= 24 { + (area.height * 2 / 5).max(8) + } else { + (area.height / 2).max(6) + }; let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(chart_height), - Constraint::Length(legend_height), + Constraint::Length(chart_height.min(area.height)), + Constraint::Length(1), Constraint::Min(0), ]) .split(area); - let list_area_height = chunks[2].height.saturating_sub(2); - let items_per_page = ((list_area_height / 2) as usize).max(1); - app.set_max_visible_items(items_per_page); - render_chart(frame, app, chunks[0]); render_legend(frame, app, chunks[1]); - render_top_models(frame, app, chunks[2], items_per_page); + + let overview = collect_overview_data(app); + render_dashboard(frame, app, chunks[2], &overview); +} + +fn collect_overview_data(app: &App) -> OverviewData { + let mut overview = OverviewData::default(); + + for day in &app.data.daily { + overview.tokens = overview + .tokens + .checked_add(&day.tokens) + .expect("overview token buckets exceed u64::MAX"); + if day.tokens.total() > 0 || day.message_count > 0 || day.turn_count > 0 { + overview.active_days += 1; + } + + for (harness, source) in &day.source_breakdown { + let harness_entry = overview.harnesses.entry(harness.clone()).or_default(); + harness_entry.tokens = harness_entry + .tokens + .checked_add(source.tokens.total()) + .expect("overview harness token total exceeds u64::MAX"); + harness_entry.cost += source.cost; + + for (model_key, model) in &source.models { + let canonical = + canonical_model_key(model_key, &model.display_name, &model.color_key); + harness_entry.models.insert(canonical.clone()); + + let entry = overview.models.entry(canonical).or_default(); + if entry.provider.is_empty() && !model.provider.is_empty() { + entry.provider = model.provider.clone(); + } + entry.tokens = entry + .tokens + .checked_add(model.tokens.total()) + .expect("overview model token total exceeds u64::MAX"); + entry.cost += model.cost; + } + } + } + + overview +} + +fn canonical_model_key(model_key: &str, display_name: &str, color_key: &str) -> String { + if !color_key.is_empty() { + color_key.to_string() + } else if !display_name.is_empty() { + display_name.to_string() + } else { + model_key.to_string() + } } fn render_chart(frame: &mut Frame, app: &App, area: Rect) { - let group_by = app.group_by.borrow().clone(); + if area.is_empty() { + return; + } let data: Vec = match app.chart_granularity { ChartGranularity::Daily => app @@ -83,33 +127,34 @@ fn render_chart(frame: &mut Frame, app: &App, area: Rect) { .collect::>() .into_iter() .rev() - .map(|d| { - let mut models_by_key = std::collections::BTreeMap::::new(); - for source_info in d.source_breakdown.values() { - for (key, info) in &source_info.models { - let entry = - models_by_key - .entry(key.clone()) - .or_insert_with(|| ModelSegment { - model_id: info.display_name.clone(), - tokens: 0, - color: app.model_color_for( - &info.provider, - overview_color_key(&group_by, &info.color_key), - ), - }); + .map(|day| { + let mut models = BTreeMap::::new(); + for source in day.source_breakdown.values() { + for (model_key, model) in &source.models { + let canonical = + canonical_model_key(model_key, &model.display_name, &model.color_key); + let entry = models.entry(canonical).or_default(); + if entry.provider.is_empty() && !model.provider.is_empty() { + entry.provider = model.provider.clone(); + } entry.tokens = entry .tokens - .checked_add(info.tokens.total()) - .expect("overview model token total exceeds u64::MAX"); + .checked_add(model.tokens.total()) + .expect("overview chart token total exceeds u64::MAX"); } } - let models: Vec = models_by_key.into_values().collect(); StackedBarData { - date: d.date.format("%m/%d").to_string(), - models, - total: d.tokens.total(), + date: day.date.format("%m/%d").to_string(), + models: models + .into_iter() + .map(|(model, aggregate)| ModelSegment { + color: app.model_color_for(&aggregate.provider, &model), + model_id: model, + tokens: aggregate.tokens, + }) + .collect(), + total: day.tokens.total(), } }) .collect(), @@ -121,21 +166,32 @@ fn render_chart(frame: &mut Frame, app: &App, area: Rect) { .collect::>() .into_iter() .rev() - .map(|h| { - let models: Vec = h - .models - .values() - .map(|info| ModelSegment { - model_id: info.display_name.clone(), - tokens: info.tokens.total(), - color: app.model_color_for(&info.provider, &info.color_key), - }) - .collect(); + .map(|hour| { + let mut models = BTreeMap::::new(); + for (model_key, model) in &hour.models { + let canonical = + canonical_model_key(model_key, &model.display_name, &model.color_key); + let entry = models.entry(canonical).or_default(); + if entry.provider.is_empty() && !model.provider.is_empty() { + entry.provider = model.provider.clone(); + } + entry.tokens = entry + .tokens + .checked_add(model.tokens.total()) + .expect("overview hourly chart token total exceeds u64::MAX"); + } StackedBarData { - date: h.datetime.format("%d %H:%M").to_string(), - models, - total: h.tokens.total(), + date: hour.datetime.format("%d %H:%M").to_string(), + models: models + .into_iter() + .map(|(model, aggregate)| ModelSegment { + color: app.model_color_for(&aggregate.provider, &model), + model_id: model, + tokens: aggregate.tokens, + }) + .collect(), + total: hour.tokens.total(), } }) .collect(), @@ -145,262 +201,327 @@ fn render_chart(frame: &mut Frame, app: &App, area: Rect) { } fn render_legend(frame: &mut Frame, app: &App, area: Rect) { - let legend_limit = if app.is_narrow() { 3 } else { 5 }; - let max_name_width = if app.is_narrow() { 12 } else { 18 }; - let muted_color = app.theme.muted; - let group_by = app.group_by.borrow().clone(); - - let top_models: Vec<(String, Color)> = app - .get_sorted_models() - .iter() - .take(legend_limit) - .map(|m| { - ( - overview_model_label(&group_by, &m.model, m.workspace_label.as_deref()), - app.model_color_for(&m.provider, &m.model), - ) - }) - .collect(); - - if top_models.is_empty() { + if area.is_empty() { return; } - let mut spans: Vec = Vec::new(); - for (i, (model_name, color)) in top_models.iter().enumerate() { - let name = truncate_string(model_name, max_name_width); - - spans.push(Span::styled("●", Style::default().fg(*color))); - spans.push(Span::raw(format!(" {}", name))); - - if i < top_models.len() - 1 { - spans.push(Span::styled(" ·", Style::default().fg(muted_color))); + let overview = collect_overview_data(app); + let mut models: Vec<_> = overview.models.iter().collect(); + models.sort_by(|(left_name, left), (right_name, right)| { + right + .tokens + .cmp(&left.tokens) + .then_with(|| right.cost.total_cmp(&left.cost)) + .then_with(|| left_name.cmp(right_name)) + }); + + let limit = if app.is_narrow() { 3 } else { 5 }; + let name_width = if app.is_narrow() { 12 } else { 18 }; + let mut spans = Vec::new(); + for (index, (model, aggregate)) in models.into_iter().take(limit).enumerate() { + if index > 0 { + spans.push(Span::styled(" · ", Style::default().fg(app.theme.muted))); } + spans.push(Span::styled( + "●", + Style::default().fg(app.model_color_for(&aggregate.provider, model)), + )); + spans.push(Span::raw(format!( + " {}", + truncate_string(model, name_width) + ))); } - let legend_line = Line::from(spans); - let paragraph = Paragraph::new(legend_line); - frame.render_widget(paragraph, area); + if !spans.is_empty() { + frame.render_widget(Paragraph::new(Line::from(spans)), area); + } } -fn render_top_models(frame: &mut Frame, app: &mut App, area: Rect, items_per_page: usize) { - use super::widgets::format_cost; - use crate::tui::app::SortField; - - let theme_border = app.theme.border; - let theme_accent = app.theme.accent; - let theme_background = app.theme.background; - let theme_muted = app.theme.muted; - let theme_foreground = app.theme.foreground; - let theme_selection = app.theme.selection; - let secondary_text_style = app.theme.secondary_text_style(); - let subtle_text_style = app.theme.subtle_text_style(); - let scroll_offset = app.scroll_offset; - let selected_index = app.selected_index; - let is_narrow = app.is_narrow(); - let is_very_narrow = app.is_very_narrow(); - let sort_field = app.sort_field; - let total_cost = app.data.total_cost; - let group_by = app.group_by.borrow().clone(); - - let models_data: Vec = app - .get_sorted_models() - .iter() - .map(|m| ModelRowData { - model: m.model.clone(), - provider: m.provider.clone(), - workspace_label: m.workspace_label.clone(), - tokens_input: m.tokens.input, - displayed_output: m.tokens.displayed_output(), - tokens_cache_read: m.tokens.cache_read, - tokens_cache_write: m.tokens.cache_write, - cost: m.cost, - }) - .collect(); - - let title = if is_very_narrow { - "Top Models".to_string() - } else { - match sort_field { - SortField::Tokens => "Models by Tokens".to_string(), - _ => "Models by Cost".to_string(), - } - }; +fn render_dashboard(frame: &mut Frame, app: &App, area: Rect, overview: &OverviewData) { + if area.is_empty() { + return; + } - let title_right = if is_very_narrow { - format_cost(total_cost) + if area.width >= 88 { + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(48), Constraint::Percentage(52)]) + .split(area); + render_summary_panel(frame, app, columns[0], overview); + render_profile_panel(frame, app, columns[1], overview); + } else if area.height >= 13 { + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(8), Constraint::Min(5)]) + .split(area); + render_summary_panel(frame, app, rows[0], overview); + render_profile_panel(frame, app, rows[1], overview); } else { - format!("Total: {}", format_cost(total_cost)) - }; + render_summary_panel(frame, app, area, overview); + } +} - let block = Block::default() +fn dashboard_block<'a>(app: &App, title: &'a str) -> Block<'a> { + Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(theme_border)) + .border_style(Style::default().fg(app.theme.border)) .title(Span::styled( - format!(" {} ", title), + format!(" {title} "), Style::default() - .fg(theme_accent) + .fg(app.theme.accent) .add_modifier(Modifier::BOLD), )) - .title_top( - Line::from(Span::styled( - format!(" {} ", title_right), - Style::default().fg(Color::Green), - )) - .right_aligned(), - ) - .style(Style::default().bg(theme_background)); + .style(Style::default().bg(app.theme.background)) +} + +fn render_summary_panel(frame: &mut Frame, app: &App, area: Rect, overview: &OverviewData) { + if area.is_empty() { + return; + } + let block = dashboard_block(app, "Overview"); let inner = block.inner(area); frame.render_widget(block, area); - - if models_data.is_empty() { - let empty = Paragraph::new("No data available") - .style(Style::default().fg(theme_muted)) - .alignment(Alignment::Center); - frame.render_widget(empty, inner); + if inner.is_empty() { return; } - let total = models_data + let favorite_model = overview + .models + .iter() + .max_by(|(left_name, left), (right_name, right)| { + left.tokens + .cmp(&right.tokens) + .then_with(|| left.cost.total_cmp(&right.cost)) + .then_with(|| right_name.cmp(left_name)) + }) + .map(|(name, _)| name.as_str()) + .unwrap_or("—"); + let favorite_harness = overview + .harnesses .iter() - .map(|m| if m.cost.is_finite() { m.cost } else { 0.0 }) - .sum::() - .max(0.01); - let models_len = models_data.len(); - let start = scroll_offset.min(models_len); - let end = (start + items_per_page).min(models_len); - let max_name_width = if is_narrow { 20 } else { 35 }; - - if start >= models_len { + .max_by(|(left_name, left), (right_name, right)| { + left.tokens + .cmp(&right.tokens) + .then_with(|| left.cost.total_cmp(&right.cost)) + .then_with(|| right_name.cmp(left_name)) + }) + .map(|(name, _)| get_client_display_name(name)) + .unwrap_or_else(|| "—".to_string()); + let issue_count = app.data.health.issue_count(); + + let lines = vec![ + metric_pair_line( + app, + "Tokens", + format_tokens(app.data.total_tokens), + Color::Cyan, + "Cost", + format_cost(app.data.total_cost), + Color::Green, + ), + metric_pair_line( + app, + "Favorite model", + truncate_string(favorite_model, 24), + app.model_color(favorite_model), + "Favorite harness", + truncate_string(&favorite_harness, 20), + app.theme.foreground, + ), + metric_pair_line( + app, + "Models", + overview.models.len().to_string(), + Color::Cyan, + "Harnesses", + overview.harnesses.len().to_string(), + Color::Cyan, + ), + metric_pair_line( + app, + "Active days", + overview.active_days.to_string(), + Color::Cyan, + "Agent profiles", + app.data.agents.len().to_string(), + Color::Cyan, + ), + metric_pair_line( + app, + "Source data", + format_bytes(app.data.health.source_data_bytes), + app.theme.foreground, + "Data issues", + issue_count.to_string(), + if issue_count == 0 { + app.theme.muted + } else { + Color::Yellow + }, + ), + ]; + + frame.render_widget( + Paragraph::new( + lines + .into_iter() + .take(inner.height as usize) + .collect::>(), + ), + inner, + ); +} + +#[allow(clippy::too_many_arguments)] +fn metric_pair_line( + app: &App, + left_label: &str, + left_value: String, + left_color: Color, + right_label: &str, + right_value: String, + right_color: Color, +) -> Line<'static> { + let separator = if app.is_narrow() { + " · " + } else { + " │ " + }; + Line::from(vec![ + Span::styled( + format!("{left_label}: "), + Style::default().fg(app.theme.muted), + ), + Span::styled(left_value, Style::default().fg(left_color)), + Span::styled(separator, Style::default().fg(app.theme.border)), + Span::styled( + format!("{right_label}: "), + Style::default().fg(app.theme.muted), + ), + Span::styled(right_value, Style::default().fg(right_color)), + ]) +} + +fn render_profile_panel(frame: &mut Frame, app: &App, area: Rect, overview: &OverviewData) { + if area.is_empty() { return; } - let mut y = inner.y; - for (i, model) in models_data[start..end].iter().enumerate() { - if y + 1 >= inner.y + inner.height { - break; - } - - let idx = i + start; - let is_selected = idx == selected_index; - let row_style = if is_selected { - Style::default().bg(theme_selection).fg(theme_foreground) - } else { - Style::default() - }; - - let model_color = app.model_color_for(&model.provider, &model.model); - let display_name = - overview_model_label(&group_by, &model.model, model.workspace_label.as_deref()); - let name = truncate_string(&display_name, max_name_width); - let percentage = if model.cost.is_finite() && total.is_finite() && total > 0.0 { - (model.cost / total) * 100.0 - } else { - 0.0 - }; - - let line1_area = Rect::new(inner.x, y, inner.width, 1); - frame.render_widget(Paragraph::new("").style(row_style), line1_area); - - let line1_spans = vec![ - Span::styled("●", Style::default().fg(model_color)), - Span::styled( - format!(" {}", name), - Style::default() - .fg(if is_selected { - theme_foreground - } else { - model_color - }) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - format!(" ({:.1}%)", percentage), - Style::default().fg(theme_muted), - ), - ]; - let line1 = Line::from(line1_spans); - let line1_para = Paragraph::new(line1).style(row_style); - frame.render_widget(line1_para, line1_area); - - y += 1; - if y >= inner.y + inner.height { - break; - } + let block = dashboard_block(app, "Token Profile"); + let inner = block.inner(area); + frame.render_widget(block, area); + if inner.is_empty() { + return; + } - let line2_area = Rect::new(inner.x, y, inner.width, 1); - frame.render_widget(Paragraph::new("").style(row_style), line2_area); - - let line2_spans = if is_narrow { - vec![ - Span::raw(" "), - Span::styled(format_tokens(model.tokens_input), secondary_text_style), - Span::styled("/", subtle_text_style), - Span::styled(format_tokens(model.displayed_output), secondary_text_style), - Span::styled("/", subtle_text_style), - Span::styled(format_tokens(model.tokens_cache_read), secondary_text_style), - Span::styled("/", subtle_text_style), + let total = overview.tokens.total(); + let buckets = [ + ( + "Input", + overview.tokens.input, + app.theme.metric_input_style(), + ), + ( + "Output", + overview.tokens.displayed_output(), + app.theme.metric_output_style(), + ), + ( + "Cache read", + overview.tokens.cache_read, + app.theme.metric_cache_read_style(), + ), + ( + "Cache write", + overview.tokens.cache_write, + app.theme.metric_cache_write_style(), + ), + ]; + let bar_width = (inner.width as usize).saturating_sub(31).clamp(1, 40); + let lines = buckets + .into_iter() + .map(|(label, value, style)| { + let percentage = if total > 0 { + value as f64 / total as f64 * 100.0 + } else { + 0.0 + }; + let filled = if total > 0 { + ((value as f64 / total as f64) * bar_width as f64).round() as usize + } else { + 0 + } + .min(bar_width); + Line::from(vec![ + Span::styled(format!("{label:<12}"), Style::default().fg(app.theme.muted)), + Span::styled("█".repeat(filled), style), Span::styled( - format_tokens(model.tokens_cache_write), - secondary_text_style, + "░".repeat(bar_width.saturating_sub(filled)), + app.theme.subtle_text_style(), ), - ] - } else { - vec![ - Span::styled(" In: ", subtle_text_style), - Span::styled(format_tokens(model.tokens_input), secondary_text_style), - Span::styled(" · Out: ", subtle_text_style), - Span::styled(format_tokens(model.displayed_output), secondary_text_style), - Span::styled(" · CR: ", subtle_text_style), - Span::styled(format_tokens(model.tokens_cache_read), secondary_text_style), - Span::styled(" · CW: ", subtle_text_style), Span::styled( - format_tokens(model.tokens_cache_write), - secondary_text_style, + format!(" {:>5.1}% ", percentage), + Style::default().fg(app.theme.muted), ), - ] - }; + Span::styled( + format_tokens(value), + Style::default().fg(app.theme.foreground), + ), + ]) + }) + .take(inner.height as usize) + .collect::>(); - let line2 = Line::from(line2_spans); - let line2_para = Paragraph::new(line2).style(row_style); - frame.render_widget(line2_para, line2_area); + frame.render_widget(Paragraph::new(lines), inner); +} - y += 1; +fn format_bytes(bytes: u64) -> String { + const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; + let mut value = bytes as f64; + let mut unit = 0usize; + while value >= 1024.0 && unit + 1 < UNITS.len() { + value /= 1024.0; + unit += 1; } - - if models_len > items_per_page { - let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) - .begin_symbol(Some("▲")) - .end_symbol(Some("▼")) - .track_symbol(Some("│")) - .thumb_symbol("█"); - - let mut scrollbar_state = - viewport_scrollbar_state(models_len, scroll_offset, items_per_page); - - frame.render_stateful_widget( - scrollbar, - inner.inner(Margin { - horizontal: 0, - vertical: 0, - }), - &mut scrollbar_state, - ); + if unit == 0 { + format!("{} {}", bytes, UNITS[unit]) + } else { + format!("{value:.1} {}", UNITS[unit]) } } -fn truncate_string(s: &str, max_chars: usize) -> String { +fn truncate_string(value: &str, max_chars: usize) -> String { if max_chars == 0 { return String::new(); } - let char_count = s.chars().count(); - if char_count <= max_chars { - s.to_string() - } else if max_chars == 1 { - "…".to_string() - } else { - let head: String = s.chars().take(max_chars - 1).collect(); - format!("{}…", head) + if value.chars().count() <= max_chars { + return value.to_string(); + } + if max_chars == 1 { + return "…".to_string(); + } + format!("{}…", value.chars().take(max_chars - 1).collect::()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn canonical_model_prefers_color_key_over_grouped_label() { + assert_eq!( + canonical_model_key( + "workspace-a / claude-sonnet-4", + "workspace-a / claude-sonnet-4", + "claude-sonnet-4", + ), + "claude-sonnet-4" + ); + } + + #[test] + fn source_size_uses_binary_units() { + assert_eq!(format_bytes(1_048_576), "1.0 MiB"); } } diff --git a/crates/tokscale-cli/src/tui/ui/stats.rs b/crates/tokscale-cli/src/tui/ui/stats.rs index 23d4e1907..3fa225e2c 100644 --- a/crates/tokscale-cli/src/tui/ui/stats.rs +++ b/crates/tokscale-cli/src/tui/ui/stats.rs @@ -1,127 +1,88 @@ use ratatui::prelude::*; use ratatui::widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation}; +use crate::tui::app::{App, ClickAction}; use crate::tui::colors::get_client_color; use super::widgets::{ format_cost, format_tokens, get_client_display_name, truncate_model_display_name, - truncate_model_display_name_to, viewport_scrollbar_state, + viewport_scrollbar_state, }; -use crate::tui::app::{App, ClickAction}; const CELL_WIDTH: u16 = 2; -const GRAPH_PANEL_H: u16 = 12; -const STATS_PANEL_H: u16 = 12; -const STATS_COMPACT_H: u16 = 8; -const BREAKDOWN_MIN_H: u16 = 6; +const GRAPH_PANEL_H: u16 = 14; +const DAY_INSIGHTS_MIN_H: u16 = 5; const MONTH_LABELS: &[&str] = &[ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", ]; const DAY_LABELS: &[&str] = &["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum StatsLayoutMode { - StatsOnly, - StatsWithBreakdown, - BreakdownOnly, -} - -fn stats_layout_mode(area_height: u16, has_selected_cell: bool) -> StatsLayoutMode { - if !has_selected_cell { - return StatsLayoutMode::StatsOnly; +pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { + if area.is_empty() { + return; } - if area_height >= GRAPH_PANEL_H + STATS_COMPACT_H + BREAKDOWN_MIN_H { - StatsLayoutMode::StatsWithBreakdown - } else { - StatsLayoutMode::BreakdownOnly + let graph_height = GRAPH_PANEL_H.min(area.height.saturating_sub(DAY_INSIGHTS_MIN_H)); + if graph_height < 6 { + render_graph(frame, app, area); + return; } -} - -pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { - let has_selected_cell = app.selected_graph_cell.is_some(); - match stats_layout_mode(area.height, has_selected_cell) { - StatsLayoutMode::StatsWithBreakdown => { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(GRAPH_PANEL_H), - Constraint::Length(STATS_COMPACT_H), - Constraint::Min(BREAKDOWN_MIN_H), - ]) - .split(area); - render_graph(frame, app, chunks[0]); - render_stats_panel(frame, app, chunks[1]); - render_breakdown_panel(frame, app, chunks[2]); - } - StatsLayoutMode::BreakdownOnly => { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(GRAPH_PANEL_H), - Constraint::Min(BREAKDOWN_MIN_H), - ]) - .split(area); - - render_graph(frame, app, chunks[0]); - render_breakdown_panel(frame, app, chunks[1]); - } - StatsLayoutMode::StatsOnly => { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(GRAPH_PANEL_H), - Constraint::Min(STATS_PANEL_H), - ]) - .split(area); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(graph_height), + Constraint::Min(DAY_INSIGHTS_MIN_H), + ]) + .split(area); - render_graph(frame, app, chunks[0]); - render_stats_panel(frame, app, chunks[1]); - } - } + render_graph(frame, app, chunks[0]); + render_day_insights(frame, app, chunks[1]); } fn render_graph(frame: &mut Frame, app: &mut App, area: Rect) { - let theme_border = app.theme.border; - let theme_accent = app.theme.accent; - let theme_background = app.theme.background; - let theme_muted = app.theme.muted; - let theme_colors = app.theme.colors; - let subtle_text_style = app.theme.subtle_text_style(); - let selected_cell = app.selected_graph_cell; - let is_narrow = app.is_narrow(); - let block = Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(theme_border)) + .border_style(Style::default().fg(app.theme.border)) .title(Span::styled( " Contribution Graph (52 weeks) ", Style::default() - .fg(theme_accent) + .fg(app.theme.accent) .add_modifier(Modifier::BOLD), )) - .style(Style::default().bg(theme_background)); - + .style(Style::default().bg(app.theme.background)); let inner = block.inner(area); frame.render_widget(block, area); + if inner.is_empty() { + return; + } - let graph = match &app.data.graph { - Some(g) => g.clone(), - None => return, + let Some(graph) = app.data.graph.clone() else { + frame.render_widget( + Paragraph::new("No contribution data available") + .style(Style::default().fg(app.theme.muted)) + .alignment(Alignment::Center), + inner, + ); + return; }; + let selected_cell = app.selected_graph_cell; + let is_narrow = app.is_narrow(); let label_width = if is_narrow { 2u16 } else { 4u16 }; - let graph_start_x = inner.x + label_width; - let graph_start_y = inner.y + 2; + let graph_start_x = inner.x.saturating_add(label_width); + let graph_start_y = inner.y.saturating_add(2); + let graph_bottom = inner.bottom(); for (day_idx, label) in DAY_LABELS.iter().enumerate() { if day_idx % 2 == 1 { - let y = graph_start_y + day_idx as u16; - if y < inner.y + inner.height { - let display_label = if is_narrow { "" } else { *label }; - let text = Paragraph::new(display_label).style(Style::default().fg(theme_muted)); - frame.render_widget(text, Rect::new(inner.x, y, label_width, 1)); + let y = graph_start_y.saturating_add(day_idx as u16); + if y < graph_bottom { + frame.render_widget( + Paragraph::new(if is_narrow { "" } else { *label }) + .style(Style::default().fg(app.theme.muted)), + Rect::new(inner.x, y, label_width, 1), + ); } } } @@ -129,70 +90,61 @@ fn render_graph(frame: &mut Frame, app: &mut App, area: Rect) { let max_weeks = (inner.width.saturating_sub(label_width) / CELL_WIDTH) as usize; let weeks_to_show = graph.weeks.len().min(max_weeks); let start_week = graph.weeks.len().saturating_sub(weeks_to_show); - + let colors = app.theme.colors; let intensity_color = |intensity: f64| -> Color { - let safe_intensity = if intensity.is_finite() { + let value = if intensity.is_finite() { intensity.clamp(0.0, 1.0) } else { 0.0 }; - let idx = match safe_intensity { + let index = match value { x if x <= 0.0 => 0, x if x < 0.25 => 1, x if x < 0.50 => 2, x if x < 0.75 => 3, _ => 4, }; - theme_colors[idx] + colors[index] }; - let mut click_areas_to_add: Vec<(Rect, usize, usize)> = Vec::new(); - + let mut click_areas = Vec::new(); for (week_idx, week) in graph.weeks.iter().skip(start_week).enumerate() { - let x = graph_start_x + (week_idx as u16 * CELL_WIDTH); - - for (day_idx, day_opt) in week.iter().enumerate() { - let y = graph_start_y + day_idx as u16; - - if x >= inner.x + inner.width || y >= inner.y + inner.height { + let x = graph_start_x.saturating_add(week_idx as u16 * CELL_WIDTH); + for (day_idx, day) in week.iter().enumerate() { + let y = graph_start_y.saturating_add(day_idx as u16); + if x >= inner.right() || y >= graph_bottom { continue; } - let actual_week_idx = week_idx + start_week; - let is_selected = selected_cell == Some((actual_week_idx, day_idx)); - - let (cell_str, style) = match day_opt { + let actual_week = week_idx + start_week; + let selected = selected_cell == Some((actual_week, day_idx)); + let (symbol, style) = match day { Some(day) => { let color = intensity_color(day.intensity); - if is_selected { + if selected { ("▓▓", Style::default().fg(Color::White).bg(color)) } else { ("██", Style::default().fg(color)) } } - None => { - if is_selected { - ("▓▓", Style::default().fg(Color::White).bg(theme_colors[0])) - } else { - ("· ", subtle_text_style) - } - } + None if selected => ( + "▓▓", + Style::default().fg(Color::White).bg(app.theme.colors[0]), + ), + None => ("· ", app.theme.subtle_text_style()), }; - - let cell = Paragraph::new(cell_str).style(style); - frame.render_widget(cell, Rect::new(x, y, CELL_WIDTH, 1)); - - click_areas_to_add.push((Rect::new(x, y, CELL_WIDTH, 1), actual_week_idx, day_idx)); + frame.render_widget( + Paragraph::new(symbol).style(style), + Rect::new(x, y, CELL_WIDTH, 1), + ); + click_areas.push((Rect::new(x, y, CELL_WIDTH, 1), actual_week, day_idx)); } } - - for (rect, week, day) in click_areas_to_add { + for (rect, week, day) in click_areas { app.add_click_area(rect, ClickAction::GraphCell { week, day }); } - let month_y = inner.y; - let mut current_month: Option = None; - + let mut current_month = None; for (week_idx, week) in graph.weeks.iter().skip(start_week).enumerate() { if let Some(Some(day)) = week.first() { let month = day @@ -201,448 +153,237 @@ fn render_graph(frame: &mut Frame, app: &mut App, area: Rect) { .to_string() .parse::() .unwrap_or(1) - - 1; + .saturating_sub(1); if current_month != Some(month) { current_month = Some(month); - let x = graph_start_x + (week_idx as u16 * CELL_WIDTH); - if x + 3 < inner.x + inner.width && month < MONTH_LABELS.len() { - let label = - Paragraph::new(MONTH_LABELS[month]).style(Style::default().fg(theme_muted)); - frame.render_widget(label, Rect::new(x, month_y, 3, 1)); + let x = graph_start_x.saturating_add(week_idx as u16 * CELL_WIDTH); + if x.saturating_add(3) < inner.right() && month < MONTH_LABELS.len() { + frame.render_widget( + Paragraph::new(MONTH_LABELS[month]) + .style(Style::default().fg(app.theme.muted)), + Rect::new(x, inner.y, 3, 1), + ); } } } } + + render_graph_metrics(frame, app, inner, &graph); +} + +fn render_graph_metrics( + frame: &mut Frame, + app: &App, + inner: Rect, + graph: &crate::tui::data::GraphData, +) { + let active_days = graph + .weeks + .iter() + .flat_map(|week| week.iter()) + .filter_map(|day| day.as_ref()) + .filter(|day| day.tokens > 0) + .count(); + let total_days = graph + .weeks + .iter() + .flat_map(|week| week.iter()) + .filter(|day| day.is_some()) + .count(); + + let metrics_y = inner.y.saturating_add(9); + if metrics_y < inner.bottom() { + let metrics = Line::from(vec![ + Span::styled("Current ", Style::default().fg(app.theme.muted)), + Span::styled( + format!("{}d", app.data.current_streak), + Style::default().fg(Color::Cyan), + ), + Span::styled(" · Longest ", Style::default().fg(app.theme.muted)), + Span::styled( + format!("{}d", app.data.longest_streak), + Style::default().fg(Color::Cyan), + ), + Span::styled(" · Active ", Style::default().fg(app.theme.muted)), + Span::styled( + format!("{active_days}/{total_days}"), + Style::default().fg(Color::Cyan), + ), + ]); + frame.render_widget( + Paragraph::new(metrics), + Rect::new(inner.x, metrics_y, inner.width, 1), + ); + } + + let legend_y = inner.y.saturating_add(10); + if legend_y < inner.bottom() { + let legend = Line::from(vec![ + Span::styled("Less ", Style::default().fg(app.theme.muted)), + Span::styled("· ", app.theme.subtle_text_style()), + Span::styled("██", Style::default().fg(app.theme.colors[1])), + Span::raw(" "), + Span::styled("██", Style::default().fg(app.theme.colors[2])), + Span::raw(" "), + Span::styled("██", Style::default().fg(app.theme.colors[3])), + Span::raw(" "), + Span::styled("██", Style::default().fg(app.theme.colors[4])), + Span::styled(" More", Style::default().fg(app.theme.muted)), + Span::styled( + " select a day with mouse or keyboard", + Style::default().fg(app.theme.muted), + ), + ]); + frame.render_widget( + Paragraph::new(legend), + Rect::new(inner.x, legend_y, inner.width, 1), + ); + } } -fn render_stats_panel(frame: &mut Frame, app: &App, area: Rect) { +fn render_day_insights(frame: &mut Frame, app: &mut App, area: Rect) { let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(app.theme.border)) .title(Span::styled( - " Stats ", + " Day Insights ", Style::default() .fg(app.theme.accent) .add_modifier(Modifier::BOLD), )) .style(Style::default().bg(app.theme.background)); - let inner = block.inner(area); frame.render_widget(block, area); - - if inner.height == 0 || inner.width == 0 { + if inner.is_empty() { return; } - let is_narrow = app.is_narrow(); - let graph = &app.data.graph; - - let total_tokens: u64 = graph - .as_ref() - .map(|g| { - g.weeks - .iter() - .flat_map(|w| w.iter()) - .filter_map(|d| d.as_ref()) - .map(|d| d.tokens) - .sum() - }) - .unwrap_or(0); - - let total_cost: f64 = graph - .as_ref() - .map(|g| { - g.weeks - .iter() - .flat_map(|w| w.iter()) - .filter_map(|d| d.as_ref()) - .map(|d| d.cost) - .sum() - }) - .unwrap_or(0.0); - - let active_days: u32 = graph - .as_ref() - .map(|g| { - g.weeks - .iter() - .flat_map(|w| w.iter()) - .filter_map(|d| d.as_ref()) - .filter(|d| d.tokens > 0) - .count() as u32 - }) - .unwrap_or(0); - - let total_days: u32 = graph - .as_ref() - .map(|g| { - g.weeks - .iter() - .flat_map(|w| w.iter()) - .filter(|d| d.is_some()) - .count() as u32 - }) - .unwrap_or(365); - - let favorite_model = app.data.models.iter().max_by(|a, b| { - a.cost - .partial_cmp(&b.cost) - .unwrap_or(std::cmp::Ordering::Equal) + let selected_day = app.selected_graph_cell.and_then(|(week_idx, day_idx)| { + app.data + .graph + .as_ref() + .and_then(|graph| graph.weeks.get(week_idx)) + .and_then(|week| week.get(day_idx)) + .and_then(|day| day.clone()) }); - let favorite_model_name = favorite_model.map(|m| m.model.as_str()).unwrap_or("N/A"); - let model_color = favorite_model - .map(|m| app.model_color_for(&m.provider, &m.model)) - .unwrap_or_else(|| app.model_color("N/A")); - let sessions: u32 = app.data.models.iter().map(|m| m.session_count).sum(); - - let col1_width = if is_narrow { 36u16 } else { 60u16 }; - let col2_x = inner.x + col1_width; - let y_max = inner.y + inner.height; - - let mut y = inner.y; - - let row1_label = if is_narrow { - "Model:" - } else { - "Favorite model:" - }; - let row1 = Line::from(vec![ - Span::styled(row1_label, Style::default().fg(app.theme.muted)), - Span::raw(" "), - Span::styled( - if is_narrow { - truncate_model_display_name_to(favorite_model_name, 15) - } else { - truncate_model_display_name(favorite_model_name) - }, - Style::default().fg(model_color), - ), - ]); - frame.render_widget(Paragraph::new(row1), Rect::new(inner.x, y, col1_width, 1)); - - let tokens_label = if is_narrow { - "Tokens:" - } else { - "Total tokens:" - }; - let row1_col2 = Line::from(vec![ - Span::styled(tokens_label, Style::default().fg(app.theme.muted)), - Span::raw(" "), - Span::styled( - format_tokens(total_tokens), - Style::default().fg(Color::Cyan), - ), - ]); - frame.render_widget( - Paragraph::new(row1_col2), - Rect::new(col2_x, y, inner.width.saturating_sub(col1_width), 1), - ); - - y += 1; - if y >= y_max { - return; - } - - let row2 = Line::from(vec![ - Span::styled("Sessions:", Style::default().fg(app.theme.muted)), - Span::raw(" "), - Span::styled(sessions.to_string(), Style::default().fg(Color::Cyan)), - ]); - frame.render_widget(Paragraph::new(row2), Rect::new(inner.x, y, col1_width, 1)); - let cost_label = if is_narrow { "Cost:" } else { "Total cost:" }; - let row2_col2 = Line::from(vec![ - Span::styled(cost_label, Style::default().fg(app.theme.muted)), - Span::raw(" "), - Span::styled(format_cost(total_cost), Style::default().fg(Color::Green)), - ]); - frame.render_widget( - Paragraph::new(row2_col2), - Rect::new(col2_x, y, inner.width.saturating_sub(col1_width), 1), - ); - - y += 1; - if y >= y_max { + let Some(day) = selected_day else { + app.stats_breakdown_total_lines = 0; + app.scroll_offset = 0; + frame.render_widget( + Paragraph::new( + "Select a day in the contribution graph to inspect its harness and model usage.", + ) + .style(Style::default().fg(app.theme.muted)) + .alignment(Alignment::Center), + inner, + ); return; - } - - // Row 3: Current streak / Longest streak - let streak_label = if is_narrow { - "Streak:" - } else { - "Current streak:" }; - let row3 = Line::from(vec![ - Span::styled(streak_label, Style::default().fg(app.theme.muted)), - Span::raw(" "), - Span::styled( - format!("{} days", app.data.current_streak), - Style::default().fg(Color::Cyan), - ), - ]); - frame.render_widget(Paragraph::new(row3), Rect::new(inner.x, y, col1_width, 1)); - let longest_label = if is_narrow { - "Max streak:" - } else { - "Longest streak:" - }; - let row3_col2 = Line::from(vec![ - Span::styled(longest_label, Style::default().fg(app.theme.muted)), - Span::raw(" "), + let daily = app + .data + .daily + .iter() + .find(|usage| usage.date == day.date) + .cloned(); + let mut lines = vec![Line::from(vec![ Span::styled( - format!("{} days", app.data.longest_streak), - Style::default().fg(Color::Cyan), + day.date.format("%a, %b %d, %Y").to_string(), + Style::default() + .fg(app.theme.foreground) + .add_modifier(Modifier::BOLD), ), - ]); - frame.render_widget( - Paragraph::new(row3_col2), - Rect::new(col2_x, y, inner.width.saturating_sub(col1_width), 1), - ); - - y += 1; - if y >= y_max { - return; - } - - let active_label = if is_narrow { "Active:" } else { "Active days:" }; - let active_days_line = Line::from(vec![ - Span::styled(active_label, Style::default().fg(app.theme.muted)), - Span::raw(" "), + Span::raw(" "), + Span::styled(format_tokens(day.tokens), Style::default().fg(Color::Cyan)), + Span::raw(" "), Span::styled( - format!("{}/{}", active_days, total_days), - Style::default().fg(Color::Cyan), - ), - ]); - frame.render_widget( - Paragraph::new(active_days_line), - Rect::new(inner.x, y, col1_width, 1), - ); - - y += 2; - if y >= y_max { - return; - } - - let legend_spans = vec![ - Span::styled("Less ", Style::default().fg(app.theme.muted)), - Span::styled("· ", app.theme.subtle_text_style()), - Span::styled("██", Style::default().fg(app.theme.colors[1])), - Span::raw(" "), - Span::styled("██", Style::default().fg(app.theme.colors[2])), - Span::raw(" "), - Span::styled("██", Style::default().fg(app.theme.colors[3])), - Span::raw(" "), - Span::styled("██", Style::default().fg(app.theme.colors[4])), - Span::styled(" More", Style::default().fg(app.theme.muted)), - ]; - let legend_line = Line::from(legend_spans); - frame.render_widget( - Paragraph::new(legend_line), - Rect::new(inner.x, y, inner.width, 1), - ); - - y += 2; - if y >= y_max { - return; - } - - if !is_narrow { - let footer = Line::from(Span::styled( - format!( - "Your total spending is ${:.2} on AI coding assistants!", - total_cost - ), - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::ITALIC), - )); - frame.render_widget( - Paragraph::new(footer), - Rect::new(inner.x, y, inner.width, 1), - ); - } -} - -fn render_breakdown_panel(frame: &mut Frame, app: &mut App, area: Rect) { - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(app.theme.border)) - .title(Span::styled( - " Day Breakdown (ESC to close) ", + format_cost(day.cost), Style::default() - .fg(app.theme.accent) + .fg(Color::Green) .add_modifier(Modifier::BOLD), - )) - .style(Style::default().bg(app.theme.background)); - - let inner = block.inner(area); - frame.render_widget(block, area); - - let (week_idx, day_idx) = match app.selected_graph_cell { - Some(cell) => cell, - None => return, - }; - - let graph = match &app.data.graph { - Some(g) => g, - None => { - app.stats_breakdown_total_lines = 0; - return; - } - }; - - let day = match graph - .weeks - .get(week_idx) - .and_then(|w| w.get(day_idx)) - .and_then(|d| d.as_ref()) - { - Some(d) => d, - None => { - app.stats_breakdown_total_lines = 0; - let no_data = Paragraph::new("No data for this day") - .style(Style::default().fg(app.theme.muted)) - .alignment(Alignment::Center); - frame.render_widget(no_data, inner); - return; - } - }; - - let daily_usage = app.data.daily.iter().find(|d| d.date == day.date); + ), + ])]; - let mut lines = vec![ - Line::from(vec![ + if let Some(daily) = daily { + lines.push(Line::from(vec![ + Span::styled("Messages ", Style::default().fg(app.theme.muted)), Span::styled( - day.date.format("%a, %b %d, %Y").to_string(), - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), + daily.message_count.to_string(), + Style::default().fg(Color::Cyan), ), - Span::raw(" "), - Span::styled(format_tokens(day.tokens), Style::default().fg(Color::Cyan)), - Span::raw(" "), + Span::styled(" · Turns ", Style::default().fg(app.theme.muted)), Span::styled( - format_cost(day.cost), - Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD), + daily.turn_count.to_string(), + Style::default().fg(Color::Cyan), ), - ]), - Line::from(""), - ]; - - if let Some(daily) = daily_usage { - if daily.source_breakdown.is_empty() { - lines.push(Line::from(Span::styled( - "No detailed breakdown available", - Style::default().fg(app.theme.muted), - ))); - } else { - for (client, source_info) in &daily.source_breakdown { - let mut models: Vec<_> = source_info.models.values().collect(); - models.sort_by(|a, b| { - b.tokens - .total() - .cmp(&a.tokens.total()) - .then_with(|| a.display_name.cmp(&b.display_name)) - }); - - let client_color = app.theme.color(get_client_color(client)); - let client_name = get_client_display_name(client); - let model_count = models.len(); - let plural = if model_count > 1 { "s" } else { "" }; - + Span::styled(" · Harnesses ", Style::default().fg(app.theme.muted)), + Span::styled( + daily.source_breakdown.len().to_string(), + Style::default().fg(Color::Cyan), + ), + ])); + lines.push(Line::default()); + + let mut sources: Vec<_> = daily.source_breakdown.iter().collect(); + sources.sort_by(|(left_name, left), (right_name, right)| { + right + .tokens + .total() + .cmp(&left.tokens.total()) + .then_with(|| right.cost.total_cmp(&left.cost)) + .then_with(|| left_name.cmp(right_name)) + }); + + for (client, source) in sources { + let client_color = app.theme.color(get_client_color(client)); + lines.push(Line::from(vec![ + Span::styled( + format!("● {}", get_client_display_name(client)), + Style::default() + .fg(client_color) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" ", Style::default()), + Span::styled( + format_tokens(source.tokens.total()), + Style::default().fg(Color::Cyan), + ), + Span::styled(" ", Style::default()), + Span::styled(format_cost(source.cost), Style::default().fg(Color::Green)), + ])); + + let mut models: Vec<_> = source.models.values().collect(); + models.sort_by(|left, right| { + right + .tokens + .total() + .cmp(&left.tokens.total()) + .then_with(|| right.cost.total_cmp(&left.cost)) + .then_with(|| left.display_name.cmp(&right.display_name)) + }); + for model in models { + let model_color = app.model_color_for(&model.provider, &model.color_key); lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("●", Style::default().fg(model_color)), Span::styled( - format!("● {}", client_name), - Style::default() - .fg(client_color) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - format!(" ({} model{})", model_count, plural), - Style::default().fg(app.theme.muted), + format!(" {}", truncate_model_display_name(&model.display_name)), + Style::default().fg(app.theme.foreground), ), - Span::raw(" "), + Span::styled(" ", Style::default()), Span::styled( - format_cost(source_info.cost), - Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD), + format_tokens(model.tokens.total()), + Style::default().fg(Color::Cyan), ), + Span::styled(" ", Style::default()), + Span::styled(format_cost(model.cost), Style::default().fg(Color::Green)), ])); - - for model_info in models { - let model_color = - app.model_color_for(&model_info.provider, &model_info.color_key); - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled("●", Style::default().fg(model_color)), - Span::styled( - format!(" {}", truncate_model_display_name(&model_info.display_name)), - Style::default().fg(Color::White), - ), - ])); - - let is_narrow = app.is_narrow(); - if is_narrow { - let secondary_text_style = app.theme.secondary_text_style(); - let subtle_text_style = app.theme.subtle_text_style(); - lines.push(Line::from(vec![ - Span::styled(" ", Style::default()), - Span::styled( - format_tokens(model_info.tokens.input), - secondary_text_style, - ), - Span::styled("/", subtle_text_style), - Span::styled( - format_tokens(model_info.tokens.displayed_output()), - secondary_text_style, - ), - Span::styled("/", subtle_text_style), - Span::styled( - format_tokens(model_info.tokens.cache_read), - secondary_text_style, - ), - Span::styled("/", subtle_text_style), - Span::styled( - format_tokens(model_info.tokens.cache_write), - secondary_text_style, - ), - ])); - } else { - let secondary_text_style = app.theme.secondary_text_style(); - let subtle_text_style = app.theme.subtle_text_style(); - lines.push(Line::from(vec![ - Span::styled(" In: ", subtle_text_style), - Span::styled( - format_tokens(model_info.tokens.input), - secondary_text_style, - ), - Span::styled(" · Out: ", subtle_text_style), - Span::styled( - format_tokens(model_info.tokens.displayed_output()), - secondary_text_style, - ), - Span::styled(" · CR: ", subtle_text_style), - Span::styled( - format_tokens(model_info.tokens.cache_read), - secondary_text_style, - ), - Span::styled(" · CW: ", subtle_text_style), - Span::styled( - format_tokens(model_info.tokens.cache_write), - secondary_text_style, - ), - ])); - } - } } } } else { lines.push(Line::from(Span::styled( - "No detailed breakdown available", + "No detailed usage was recorded for this day.", Style::default().fg(app.theme.muted), ))); } @@ -650,32 +391,25 @@ fn render_breakdown_panel(frame: &mut Frame, app: &mut App, area: Rect) { let visible_height = inner.height.max(1) as usize; app.max_visible_items = visible_height; app.stats_breakdown_total_lines = lines.len(); - - if lines.is_empty() { - app.selected_index = 0; - app.scroll_offset = 0; - } else { - app.selected_index = app.selected_index.min(lines.len() - 1); - let max_scroll = lines.len().saturating_sub(visible_height); - app.scroll_offset = app.scroll_offset.min(max_scroll); - } - - let paragraph = Paragraph::new(lines).scroll((app.scroll_offset as u16, 0)); - frame.render_widget(paragraph, inner); + let max_scroll = lines.len().saturating_sub(visible_height); + app.scroll_offset = app.scroll_offset.min(max_scroll); + let visible = lines + .into_iter() + .skip(app.scroll_offset) + .take(visible_height) + .collect::>(); + frame.render_widget(Paragraph::new(visible), inner); if app.stats_breakdown_total_lines > visible_height { - let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) - .begin_symbol(Some("▲")) - .end_symbol(Some("▼")); - let mut scrollbar_state = viewport_scrollbar_state( app.stats_breakdown_total_lines, app.scroll_offset, visible_height, ); - frame.render_stateful_widget( - scrollbar, + Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("▲")) + .end_symbol(Some("▼")), area.inner(Margin { horizontal: 0, vertical: 1, @@ -690,28 +424,16 @@ mod tests { use super::*; #[test] - fn stats_layout_without_selection_keeps_graph_fixed() { - assert_eq!(stats_layout_mode(24, false), StatsLayoutMode::StatsOnly); - assert_eq!(stats_layout_mode(60, false), StatsLayoutMode::StatsOnly); - } - - #[test] - fn stats_layout_with_selection_keeps_all_panels_when_roomy() { - assert_eq!( - stats_layout_mode(GRAPH_PANEL_H + STATS_COMPACT_H + BREAKDOWN_MIN_H, true), - StatsLayoutMode::StatsWithBreakdown - ); - assert_eq!( - stats_layout_mode(60, true), - StatsLayoutMode::StatsWithBreakdown - ); - } - - #[test] - fn stats_layout_with_selection_drops_stats_when_constrained() { - assert_eq!( - stats_layout_mode(GRAPH_PANEL_H + STATS_COMPACT_H + BREAKDOWN_MIN_H - 1, true), - StatsLayoutMode::BreakdownOnly - ); + fn stats_reserves_space_for_day_insights() { + let area = Rect::new(0, 0, 100, 30); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(GRAPH_PANEL_H), + Constraint::Min(DAY_INSIGHTS_MIN_H), + ]) + .split(area); + assert_eq!(chunks[0].height, GRAPH_PANEL_H); + assert!(chunks[1].height >= DAY_INSIGHTS_MIN_H); } }